Refactor and make it nicer codewise
This commit is contained in:
parent
59067e49fe
commit
e5a9b9908d
@ -18,7 +18,7 @@
|
||||
"prepare-sources": "node scripts/prepare-sources.js",
|
||||
"compile": "yarn clean && tsc",
|
||||
"build": "yarn prepare-sources && yarn compile && node scripts/dist.js",
|
||||
"dev": "yarn prepare-sources && concurrently -k -n \"Typescript,Node,Webpack,Maildev\" -p \"[{name}]\" -c \"blue,green,red,yellow\" \"tsc --watch\" \"nodemon -i public\" \"maildev\"",
|
||||
"dev": "yarn prepare-sources && concurrently -k -n \"Typescript,Node,Webpack,Maildev\" -p \"[{name}]\" -c \"blue,green,red,yellow\" \"tsc --watch\" \"nodemon -i build\" \"maildev\"",
|
||||
"lint": "eslint .",
|
||||
"release": "yarn build && yarn lint && yarn test && cd dist && yarn publish"
|
||||
},
|
||||
|
@ -33,6 +33,7 @@ import PreviousUrlComponent from "./components/PreviousUrlComponent";
|
||||
import AddNameChangedAtToUsersMigration from "./auth/migrations/AddNameChangedAtToUsersMigration";
|
||||
import BackendController from "./helpers/BackendController";
|
||||
import FrontendToolsComponent from "./components/FrontendToolsComponent";
|
||||
import SvelteViewEngine from "./frontend/SvelteViewEngine";
|
||||
import packageJson = require('./package.json');
|
||||
|
||||
export const MIGRATIONS = [
|
||||
@ -76,7 +77,7 @@ export default class TestApp extends Application {
|
||||
|
||||
// Dynamic views and routes
|
||||
this.use(new NunjucksComponent(['test/views', 'views']));
|
||||
this.use(new FrontendToolsComponent('public', 'build'));
|
||||
this.use(new FrontendToolsComponent(new SvelteViewEngine('build/views', 'public', 'views')));
|
||||
this.use(new PreviousUrlComponent());
|
||||
|
||||
// Services
|
||||
|
39
src/Utils.ts
39
src/Utils.ts
@ -1,3 +1,7 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import * as util from "util";
|
||||
|
||||
export async function sleep(ms: number): Promise<void> {
|
||||
return await new Promise(resolve => {
|
||||
setTimeout(() => resolve(), ms);
|
||||
@ -44,3 +48,38 @@ export function getMethods<T extends { [p: string]: unknown }>(obj: T): string[]
|
||||
} while (currentObj);
|
||||
return [...properties.keys()].filter(item => typeof obj[item] === 'function');
|
||||
}
|
||||
|
||||
export const afs = {
|
||||
readFile: util.promisify(fs.readFile),
|
||||
writeFile: util.promisify(fs.writeFile),
|
||||
mkdir: util.promisify(fs.mkdir),
|
||||
readdir: util.promisify(fs.readdir),
|
||||
exists: async (file: string): Promise<boolean> =>
|
||||
await new Promise<boolean>((resolve, reject) =>
|
||||
fs.stat(file, (err) => {
|
||||
if (err == null) {
|
||||
resolve(true);
|
||||
} else if (err.code === 'ENOENT') {
|
||||
resolve(false);
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
})),
|
||||
};
|
||||
|
||||
export async function readdirRecursively(dir: string, consumer: (file: string) => Promise<void>): Promise<void> {
|
||||
const files = await afs.readdir(dir);
|
||||
|
||||
await Promise.all<void>(files.map(file => new Promise<void>((resolve, reject) => {
|
||||
file = path.join(dir, file);
|
||||
fs.stat(file, (err, stat) => {
|
||||
if (err) return reject(err);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
resolve(readdirRecursively(file, consumer));
|
||||
} else {
|
||||
resolve(consumer(file));
|
||||
}
|
||||
});
|
||||
})));
|
||||
}
|
||||
|
@ -1,622 +1,60 @@
|
||||
import {Express, Router} from "express";
|
||||
import * as fs from "fs";
|
||||
import path from "path";
|
||||
import config from "config";
|
||||
import ApplicationComponent from "../ApplicationComponent";
|
||||
import {logger} from "../Logger";
|
||||
import {ServerError} from "../HttpError";
|
||||
import * as crypto from "crypto";
|
||||
import requireFromString from "require-from-string";
|
||||
import "svelte/register";
|
||||
import {compile, preprocess} from "svelte/compiler";
|
||||
import {sveltePreprocess} from "svelte-preprocess/dist/autoProcess";
|
||||
import chokidar from "chokidar";
|
||||
import {CssResult} from "svelte/types/compiler/interfaces";
|
||||
import {PreRenderedChunk, rollup, RollupBuild, RollupOptions, RollupWatcher, RollupWatchOptions, watch} from "rollup";
|
||||
import svelte from "rollup-plugin-svelte";
|
||||
import resolve from "@rollup/plugin-node-resolve";
|
||||
import commonjs from "@rollup/plugin-commonjs";
|
||||
import {terser} from "rollup-plugin-terser";
|
||||
import cssOnlyRollupPlugin from "rollup-plugin-css-only";
|
||||
import livereloadRollupPlugin from "rollup-plugin-livereload";
|
||||
|
||||
const BACKEND_CODE_PREFIX = 'swaf.';
|
||||
const COMPILED_SVELTE_EXTENSION = '.swafview';
|
||||
import ViewEngine from "../frontend/ViewEngine";
|
||||
import {readdirRecursively} from "../Utils";
|
||||
import FileCache from "../utils/FileCache";
|
||||
|
||||
export default class FrontendToolsComponent extends ApplicationComponent {
|
||||
public static getSveltePreCompileSeparator(canonicalViewName: string): string {
|
||||
return '\n---' +
|
||||
crypto.createHash('sha1')
|
||||
.update(path.basename(path.resolve(canonicalViewName)))
|
||||
.digest('base64') +
|
||||
'---\n';
|
||||
}
|
||||
|
||||
private content: Map<string, string> = new Map();
|
||||
private readonly viewPaths: string[];
|
||||
private readonly svelteDevViewsPath: string;
|
||||
|
||||
private readonly dependencyCache: Record<string, Set<string>> = {};
|
||||
private readonly backendCodeCache: Record<string, {
|
||||
backendReplacedCode: string,
|
||||
backendLines: string[],
|
||||
}> = {};
|
||||
private readonly fileCache: Record<string, string> = {};
|
||||
|
||||
private rollup?: RollupBuild | RollupWatcher;
|
||||
private readonly publicAssetsCache: FileCache = new FileCache();
|
||||
|
||||
public constructor(
|
||||
private readonly publicAssetsPath: string,
|
||||
private readonly svelteOutputPath: string,
|
||||
...viewPaths: string[]
|
||||
private readonly viewEngine: ViewEngine,
|
||||
) {
|
||||
super();
|
||||
this.viewPaths = [
|
||||
...viewPaths.map(p => path.resolve(p)),
|
||||
path.resolve(__dirname, '../../../views'),
|
||||
path.resolve(__dirname, '../../views'),
|
||||
path.resolve(__dirname, '../views'),
|
||||
];
|
||||
this.svelteDevViewsPath = path.resolve('views');
|
||||
|
||||
if (!fs.existsSync(svelteOutputPath)) {
|
||||
fs.mkdirSync(svelteOutputPath);
|
||||
}
|
||||
}
|
||||
|
||||
public async start(app: Express): Promise<void> {
|
||||
await this.cachePublicAssets();
|
||||
// Cache public assets
|
||||
if (config.get<boolean>('view.enable_asset_cache')) {
|
||||
logger.info('Caching assets from', this.viewEngine.getPublicDir(), '...');
|
||||
await readdirRecursively(
|
||||
this.viewEngine.getPublicDir(),
|
||||
async file => await this.publicAssetsCache.load(file),
|
||||
);
|
||||
} else {
|
||||
logger.info('Asset cache disabled.');
|
||||
}
|
||||
|
||||
await this.preCompileSvelteViews();
|
||||
// Pre-compile and watch in dev mode
|
||||
if (config.get<boolean>('view.dev')) {
|
||||
await this.viewEngine.preCompileAll();
|
||||
await this.viewEngine.watch();
|
||||
}
|
||||
|
||||
const watcher = chokidar.watch(this.svelteDevViewsPath, {persistent: true});
|
||||
watcher.on('ready', () => {
|
||||
logger.debug('Watching svelte assets for changes');
|
||||
|
||||
watcher.on('add', (path) => {
|
||||
if (path.endsWith('.svelte')) {
|
||||
this.resetBundle()
|
||||
.then(() => this.preCompileSvelte(path, true))
|
||||
.then(() => this.bundle(...Object.keys(this.backendCodeCache)))
|
||||
.catch(err => logger.error(err));
|
||||
}
|
||||
});
|
||||
|
||||
watcher.on('change', (path) => {
|
||||
if (path.endsWith('.svelte')) {
|
||||
delete this.backendCodeCache[this.toCanonicalViewName(path)];
|
||||
this.preCompileSvelte(path, true)
|
||||
.then(() => this.bundle(...Object.keys(this.backendCodeCache)))
|
||||
.catch(err => logger.error(err));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.engine('svelte', (path, options, callback) => {
|
||||
this.renderSvelte(path, options as Record<string, unknown>, callback)
|
||||
// Setup express view engine
|
||||
app.engine(this.viewEngine.getExtension(), (path, options, callback) => {
|
||||
this.viewEngine.render(path, options as Record<string, unknown>, callback)
|
||||
.catch(err => callback(err));
|
||||
});
|
||||
app.set('views', this.viewPaths);
|
||||
app.set('views', this.viewEngine.getViewPaths());
|
||||
app.set('view engine', 'svelte');
|
||||
}
|
||||
|
||||
public async stop(): Promise<void> {
|
||||
await this.resetBundle();
|
||||
}
|
||||
|
||||
private async cachePublicAssets(): Promise<void> {
|
||||
if (config.get<boolean>('view.enable_asset_cache')) {
|
||||
logger.info('Caching assets from', this.publicAssetsPath, '...');
|
||||
await this.forEachFileInDirRecursively(this.publicAssetsPath, file => this.cacheFile(file));
|
||||
} else {
|
||||
logger.info('Asset cache disabled.');
|
||||
}
|
||||
}
|
||||
|
||||
private async preCompileSvelteViews(): Promise<void> {
|
||||
logger.info('Pre-compiling svelte views...');
|
||||
await this.forEachFileInDirRecursively(this.svelteDevViewsPath, async file => {
|
||||
if (file.endsWith('.svelte')) {
|
||||
await this.preCompileSvelte(file);
|
||||
}
|
||||
});
|
||||
await this.bundle(...Object.keys(this.backendCodeCache));
|
||||
await this.viewEngine.end?.();
|
||||
}
|
||||
|
||||
public async handle(router: Router): Promise<void> {
|
||||
router.use((req, res, next) => {
|
||||
res.locals.inlineAsset = (urlPath: string) => {
|
||||
if (!config.get<boolean>('view.enable_asset_cache')) {
|
||||
return fs.readFileSync(path.resolve(this.publicAssetsPath + urlPath));
|
||||
}
|
||||
|
||||
const content = this.content.get(urlPath);
|
||||
if (!content) {
|
||||
throw new ServerError('Asset ' + path + ' was not loaded.');
|
||||
}
|
||||
return content;
|
||||
return this.publicAssetsCache.getOrFail(path.join(this.viewEngine.getPublicDir(), urlPath));
|
||||
};
|
||||
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
private async forEachFileInDirRecursively(dir: string, consumer: (file: string) => Promise<void>): Promise<void> {
|
||||
await new Promise<void[]>((resolve, reject) => {
|
||||
fs.readdir(dir, (err, files) => {
|
||||
if (err) return reject(err);
|
||||
|
||||
resolve(Promise.all<void>(files.map(file => new Promise<void>((resolve, reject) => {
|
||||
file = path.join(dir, file);
|
||||
fs.stat(file, (err, stat) => {
|
||||
if (err) return reject(err);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
resolve(this.forEachFileInDirRecursively(file, consumer));
|
||||
} else {
|
||||
resolve(consumer(file));
|
||||
}
|
||||
});
|
||||
}))));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async cacheFile(file: string): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
fs.readFile(file, (err, data) => {
|
||||
if (err) return reject(data);
|
||||
|
||||
const urlPath = file.replace(this.publicAssetsPath, '');
|
||||
this.content.set(urlPath, data.toString());
|
||||
logger.debug('Loaded', file, 'as', urlPath);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async preCompileSvelte(file: string, alsoCompileDependents: boolean = false): Promise<void> {
|
||||
file = path.relative('.', file);
|
||||
const canonicalViewName = this.toCanonicalViewName(file);
|
||||
const intermediateFile = path.join(this.svelteOutputPath, canonicalViewName);
|
||||
|
||||
logger.debug(canonicalViewName + ' > ', 'Pre-compiling', file, '->', intermediateFile);
|
||||
const source = await new Promise<string>((resolve, reject) => {
|
||||
fs.readFile(file, (err, data) => {
|
||||
if (err) return reject(err);
|
||||
|
||||
resolve(data.toString());
|
||||
});
|
||||
});
|
||||
|
||||
const allBackendLines: string[] = [];
|
||||
for (const dependency of this.resolveDependencies(source, canonicalViewName)) {
|
||||
allBackendLines.push(...(await this.replaceBackendCode(dependency)).backendLines);
|
||||
}
|
||||
|
||||
const {backendReplacedCode, backendLines} = await this.replaceBackendCode(canonicalViewName, source);
|
||||
allBackendLines.push(...backendLines);
|
||||
|
||||
const preprocessedCode = await this.preProcessSvelte(backendReplacedCode, intermediateFile, canonicalViewName);
|
||||
|
||||
// Server Side Render (initial HTML, no-js)
|
||||
const ssr = this.compileSvelteSsr(preprocessedCode, intermediateFile, canonicalViewName);
|
||||
|
||||
const separator = FrontendToolsComponent.getSveltePreCompileSeparator(canonicalViewName);
|
||||
const finalCode = [
|
||||
[...new Set<string>(allBackendLines).values()].join('\n'),
|
||||
ssr.head,
|
||||
ssr.html,
|
||||
ssr.css.code,
|
||||
].join(separator);
|
||||
|
||||
const swafViewFile = path.join(this.svelteOutputPath, canonicalViewName + COMPILED_SVELTE_EXTENSION);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
fs.mkdir(path.dirname(swafViewFile), {recursive: true}, (err) => {
|
||||
if (err) return reject(err);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => fs.writeFile(swafViewFile, finalCode, err => {
|
||||
if (err) return reject(err);
|
||||
resolve();
|
||||
}));
|
||||
|
||||
if (alsoCompileDependents && Object.keys(this.dependencyCache).indexOf(canonicalViewName) >= 0) {
|
||||
logger.debug(canonicalViewName + ' > ', 'Compiling dependents...');
|
||||
for (const dependent of [...this.dependencyCache[canonicalViewName]]) {
|
||||
await this.preCompileSvelte(await this.resolveViewFromCanonicalName(dependent), true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private resolveDependencies(source: string, canonicalViewName: string): string[] {
|
||||
const dependencies: string[] = [];
|
||||
|
||||
for (const match of source.matchAll(/import .+ from ['"](.+?\.svelte)['"];/gm)) {
|
||||
dependencies.push(path.join(path.dirname(canonicalViewName), match[1]));
|
||||
}
|
||||
|
||||
// Clear existing links from cache
|
||||
for (const dependency of Object.keys(this.dependencyCache)) {
|
||||
this.dependencyCache[dependency].delete(canonicalViewName);
|
||||
}
|
||||
|
||||
// Add new links to cache
|
||||
for (const dependency of dependencies) {
|
||||
if (Object.keys(this.dependencyCache).indexOf(dependency) < 0) {
|
||||
this.dependencyCache[dependency] = new Set<string>();
|
||||
}
|
||||
this.dependencyCache[dependency].add(canonicalViewName);
|
||||
}
|
||||
|
||||
return dependencies;
|
||||
}
|
||||
|
||||
private async replaceBackendCode(canonicalViewName: string, code?: string): Promise<{
|
||||
backendReplacedCode: string,
|
||||
backendLines: string[],
|
||||
}> {
|
||||
// Cache
|
||||
if (Object.keys(this.backendCodeCache).indexOf(canonicalViewName) >= 0) {
|
||||
return this.backendCodeCache[canonicalViewName];
|
||||
}
|
||||
|
||||
const outputFile = path.join(this.svelteOutputPath, canonicalViewName);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
fs.mkdir(path.dirname(outputFile), {recursive: true}, (err) => {
|
||||
if (err) return reject(err);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
// Read source file if code was not already provided
|
||||
if (!code) {
|
||||
const file = await this.resolveViewFromCanonicalName(canonicalViewName);
|
||||
code = await new Promise<string>((resolve, reject) => {
|
||||
fs.readFile(file, (err, data) => {
|
||||
if (err) return reject(err);
|
||||
|
||||
resolve(data.toString());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Skip replace if there is no swaf export
|
||||
if (code.indexOf(`export let swaf = {};`) < 0) {
|
||||
const generated = {
|
||||
backendReplacedCode: code,
|
||||
backendLines: [],
|
||||
};
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
fs.writeFile(outputFile, generated.backendReplacedCode, (err) => {
|
||||
if (err) return reject(err);
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
this.backendCodeCache[canonicalViewName] = generated;
|
||||
return generated;
|
||||
}
|
||||
|
||||
|
||||
let output = code;
|
||||
const backendLines = new Set<string>();
|
||||
|
||||
let index = 0;
|
||||
while ((index = output.indexOf(BACKEND_CODE_PREFIX, index + 1)) >= 0) {
|
||||
// Escaping
|
||||
if (index > 0 && output[index - 1] === '\\') {
|
||||
const isEscapingEscaped: boolean = index > 1 && output[index - 2] === '\\';
|
||||
output = output.substring(0, index - 1 - (isEscapingEscaped ? 1 : 0)) +
|
||||
output.substring(index, output.length);
|
||||
continue;
|
||||
}
|
||||
|
||||
const startIndex = index + BACKEND_CODE_PREFIX.length;
|
||||
let endIndex = startIndex;
|
||||
let struct = 0;
|
||||
|
||||
while (endIndex < output.length) {
|
||||
if (['(', '[', '{'].indexOf(output[endIndex]) >= 0) struct++;
|
||||
if ([')', ']', '}'].indexOf(output[endIndex]) >= 0) {
|
||||
struct--;
|
||||
if (struct <= 0) {
|
||||
if (struct === 0) endIndex++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ([' ', '\n', '<'].indexOf(output[endIndex]) >= 0 && struct === 0) break;
|
||||
endIndex++;
|
||||
}
|
||||
|
||||
let backendLine = output.substring(startIndex, endIndex);
|
||||
if (backendLine.match(/([^()]+)\((.+?)\)/)) {
|
||||
backendLine = backendLine.replace(/([^()]+)\((.+?)\)/, "'$1', [$2]");
|
||||
} else {
|
||||
backendLine = backendLine.replace(/([^()]+)/, "'$1'");
|
||||
}
|
||||
|
||||
backendLines.add(backendLine);
|
||||
output = output.substring(0, index) +
|
||||
'swaf(' + backendLine + ')' +
|
||||
output.substring(endIndex, output.length);
|
||||
}
|
||||
|
||||
const generated = {
|
||||
backendReplacedCode: output,
|
||||
backendLines: [...backendLines],
|
||||
};
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
fs.writeFile(outputFile, generated.backendReplacedCode, (err) => {
|
||||
if (err) return reject(err);
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
this.backendCodeCache[canonicalViewName] = generated;
|
||||
|
||||
return generated;
|
||||
}
|
||||
|
||||
private async preProcessSvelte(code: string, filename: string, canonicalViewName: string): Promise<string> {
|
||||
logger.debug(canonicalViewName + ' > ', 'Preprocessing svelte', filename);
|
||||
const preprocessed = await preprocess(
|
||||
code,
|
||||
sveltePreprocess({
|
||||
typescript: {
|
||||
tsconfigFile: 'tsconfig.views.json',
|
||||
},
|
||||
}),
|
||||
{
|
||||
filename: filename,
|
||||
},
|
||||
);
|
||||
|
||||
return preprocessed.code;
|
||||
}
|
||||
|
||||
private compileSvelteSsr(code: string, filename: string, canonicalViewName: string): {
|
||||
head: string,
|
||||
css: CssResult,
|
||||
html: string,
|
||||
} {
|
||||
logger.debug(canonicalViewName + ' > ', 'Compiling svelte ssr', filename);
|
||||
const svelteSsr = compile(code, {
|
||||
dev: config.get<boolean>('view.dev'),
|
||||
generate: 'ssr',
|
||||
format: 'cjs',
|
||||
cssOutputFilename: filename + '.css',
|
||||
});
|
||||
|
||||
const locals = this.getGlobals();
|
||||
|
||||
return requireFromString(svelteSsr.js.code, filename).default.render({
|
||||
swaf: (key: string, args?: unknown[]) => {
|
||||
if (!args) return locals[key];
|
||||
|
||||
const f = locals[key];
|
||||
if (typeof f !== 'function') throw new Error(key + ' is not a function.');
|
||||
return f.call(locals, ...args);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async bundle(...canonicalViewNames: string[]): Promise<void> {
|
||||
logger.debug('Bundling');
|
||||
|
||||
const production = !config.get<boolean>('view.dev');
|
||||
const options: RollupOptions | RollupWatchOptions = {
|
||||
input: canonicalViewNames.map(name => path.join(this.svelteOutputPath, name)),
|
||||
output: {
|
||||
sourcemap: true,
|
||||
format: 'es',
|
||||
name: 'bundle',
|
||||
dir: path.join(this.publicAssetsPath, 'js'),
|
||||
entryFileNames: (chunkInfo: PreRenderedChunk): string => {
|
||||
const name = chunkInfo.facadeModuleId ?
|
||||
path.relative(this.svelteOutputPath, chunkInfo.facadeModuleId) :
|
||||
chunkInfo.name;
|
||||
return name + '.js';
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
svelte({
|
||||
preprocess: sveltePreprocess({
|
||||
typescript: {
|
||||
tsconfigFile: 'tsconfig.views.json',
|
||||
},
|
||||
}),
|
||||
compilerOptions: {
|
||||
// enable run-time checks when not in production
|
||||
dev: !production,
|
||||
hydratable: true,
|
||||
},
|
||||
}),
|
||||
// we'll extract any component CSS out into
|
||||
// a separate file - better for performance
|
||||
cssOnlyRollupPlugin({output: 'bundle.css'}),
|
||||
|
||||
// If you have external dependencies installed from
|
||||
// npm, you'll most likely need these plugins. In
|
||||
// some cases you'll need additional configuration -
|
||||
// consult the documentation for details:
|
||||
// https://github.com/rollup/plugins/tree/master/packages/commonjs
|
||||
resolve({
|
||||
browser: true,
|
||||
dedupe: ['svelte'],
|
||||
}),
|
||||
commonjs(),
|
||||
],
|
||||
watch: {
|
||||
buildDelay: 1000,
|
||||
clearScreen: false,
|
||||
},
|
||||
};
|
||||
|
||||
if (production) {
|
||||
// If we're building for production (npm run build
|
||||
// instead of npm run dev), minify
|
||||
options.plugins?.push(terser());
|
||||
} else {
|
||||
// Watch the `public` directory and refresh the
|
||||
// browser on changes when not in production
|
||||
const plugin = livereloadRollupPlugin('public');
|
||||
options.plugins?.push(plugin);
|
||||
}
|
||||
|
||||
for (const name of canonicalViewNames) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
fs.mkdir(path.dirname(path.join(this.publicAssetsPath, 'js', name)), {recursive: true}, err => {
|
||||
if (err) return reject(err);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (production) {
|
||||
if (!this.rollup) {
|
||||
this.rollup = await rollup(options);
|
||||
|
||||
await this.rollup.write({
|
||||
format: 'es',
|
||||
dir: path.join(this.publicAssetsPath, 'js'),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (!this.rollup) {
|
||||
this.rollup = watch(options);
|
||||
this.rollup.on('event', (event) => {
|
||||
if (event.code === 'ERROR' || event.code === 'BUNDLE_END') {
|
||||
event.result?.close().catch(err => logger.error(err));
|
||||
logger.debug('Bundled from watch');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async resetBundle(): Promise<void> {
|
||||
if (this.rollup) {
|
||||
logger.debug('Stopping rollup...');
|
||||
await this.rollup.close();
|
||||
this.rollup = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async renderSvelte(
|
||||
file: string,
|
||||
locals: Record<string, unknown>,
|
||||
callback: (err: Error | null, rendered?: string) => void,
|
||||
): Promise<void> {
|
||||
const canonicalViewName = this.toCanonicalViewName(file);
|
||||
const actualFile = path.join(this.svelteOutputPath, canonicalViewName + COMPILED_SVELTE_EXTENSION);
|
||||
|
||||
if (!config.get<boolean>('view.enable_asset_cache')) delete this.fileCache[actualFile];
|
||||
const view = await this.getFileContentsFromCache(actualFile);
|
||||
|
||||
|
||||
const templateFile = await this.resolveViewFromCanonicalName('layouts/svelte_layout.html');
|
||||
|
||||
if (!config.get<boolean>('view.enable_asset_cache')) delete this.fileCache[templateFile];
|
||||
let output = await this.getFileContentsFromCache(templateFile);
|
||||
|
||||
|
||||
const [
|
||||
backendLines,
|
||||
head,
|
||||
html,
|
||||
css,
|
||||
] = view.split(FrontendToolsComponent.getSveltePreCompileSeparator(canonicalViewName));
|
||||
|
||||
locals = Object.assign(this.getGlobals(), locals);
|
||||
|
||||
const localMap: Record<string, unknown> = {};
|
||||
backendLines.split('\n').forEach(line => {
|
||||
const key = line.substring(1, line.indexOf(',') >= 0 ? line.indexOf(',') - 1 : line.length - 1);
|
||||
if (line.indexOf('[') >= 0) {
|
||||
const args = line.substring(line.indexOf('[') + 1, line.length - 1)
|
||||
.split(/, *?/)
|
||||
.map(arg => {
|
||||
if (arg.startsWith("'")) return '"' + arg.substring(1, arg.length - 1) + '"';
|
||||
return arg;
|
||||
})
|
||||
.map(arg => JSON.parse(arg));
|
||||
|
||||
const f = locals[key];
|
||||
if (typeof f !== 'function') throw new Error(key + ' is not a function.');
|
||||
localMap[`'${key}', ${JSON.stringify(args)}`] = f.call(locals, ...args);
|
||||
} else {
|
||||
localMap[`'${key}'`] = locals[key];
|
||||
}
|
||||
});
|
||||
const props = JSON.stringify(localMap);
|
||||
|
||||
output = output.replace('%canonicalViewName%', canonicalViewName);
|
||||
output = output.replace('%props%', props);
|
||||
output = output.replace('%head%', head);
|
||||
output = output.replace('%html%', html);
|
||||
output = output.replace('%css%', css);
|
||||
|
||||
callback(null, output);
|
||||
}
|
||||
|
||||
private async getFileContentsFromCache(file: string): Promise<string> {
|
||||
if (!this.fileCache[file]) {
|
||||
this.fileCache[file] = await new Promise<string>((resolve, reject) => {
|
||||
fs.readFile(file, (err, data) => {
|
||||
if (err) return reject(err);
|
||||
resolve(data.toString());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return this.fileCache[file];
|
||||
}
|
||||
|
||||
private toCanonicalViewName(file: string): string {
|
||||
const resolvedFilePath = path.resolve(file);
|
||||
|
||||
let canonicalViewName: string | null = null;
|
||||
for (const viewPath of this.viewPaths) {
|
||||
if (resolvedFilePath.startsWith(viewPath)) {
|
||||
canonicalViewName = resolvedFilePath.substring(viewPath.length + 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (!canonicalViewName) throw new Error('view ' + file + ' not found');
|
||||
return canonicalViewName;
|
||||
}
|
||||
|
||||
private async resolveViewFromCanonicalName(canonicalName: string): Promise<string> {
|
||||
for (const viewPath of this.viewPaths) {
|
||||
const tryPath = path.join(viewPath, canonicalName);
|
||||
if (await new Promise<boolean>((resolve, reject) => {
|
||||
fs.stat(tryPath, (err) => {
|
||||
if (err == null) {
|
||||
resolve(true);
|
||||
} else if (err.code === 'ENOENT') {
|
||||
resolve(false);
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
})) {
|
||||
return tryPath;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('View not found from canonical name ' + canonicalName);
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: add a way to add locals from anywhere
|
||||
*/
|
||||
private getGlobals(): Record<string, unknown> {
|
||||
return {
|
||||
route: (name: string) => 'unimplemented route ' + name,
|
||||
direct: 'access',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export type SourceMap = string | Record<string, unknown> | undefined;
|
||||
|
427
src/frontend/SvelteViewEngine.ts
Normal file
427
src/frontend/SvelteViewEngine.ts
Normal file
@ -0,0 +1,427 @@
|
||||
import crypto from "crypto";
|
||||
import path from "path";
|
||||
import {PreRenderedChunk, rollup, RollupBuild, RollupOptions, RollupWatcher, RollupWatchOptions, watch} from "rollup";
|
||||
import config from "config";
|
||||
import ViewEngine from "./ViewEngine";
|
||||
import {logger} from "../Logger";
|
||||
import FileCache from "../utils/FileCache";
|
||||
import {afs} from "../Utils";
|
||||
import {compile, preprocess} from "svelte/compiler";
|
||||
import {sveltePreprocess} from "svelte-preprocess/dist/autoProcess";
|
||||
import requireFromString from "require-from-string";
|
||||
import {CssResult} from "svelte/types/compiler/interfaces";
|
||||
import svelte from "rollup-plugin-svelte";
|
||||
import cssOnlyRollupPlugin from "rollup-plugin-css-only";
|
||||
import resolve from "@rollup/plugin-node-resolve";
|
||||
import commonjs from "@rollup/plugin-commonjs";
|
||||
import {terser} from "rollup-plugin-terser";
|
||||
import livereloadRollupPlugin from "rollup-plugin-livereload";
|
||||
|
||||
const BACKEND_CODE_PREFIX = 'swaf.';
|
||||
const COMPILED_SVELTE_EXTENSION = '.swafview';
|
||||
|
||||
export default class SvelteViewEngine extends ViewEngine {
|
||||
public static getPreCompileSeparator(canonicalViewName: string): string {
|
||||
return '\n---' +
|
||||
crypto.createHash('sha1')
|
||||
.update(path.basename(path.resolve(canonicalViewName)))
|
||||
.digest('base64') +
|
||||
'---\n';
|
||||
}
|
||||
|
||||
|
||||
private readonly fileCache: FileCache = new FileCache();
|
||||
private readonly dependencyCache: Record<string, Set<string>> = {};
|
||||
private readonly backendCodeCache: Record<string, {
|
||||
backendReplacedCode: string,
|
||||
backendLines: string[],
|
||||
}> = {};
|
||||
|
||||
private rollup?: RollupBuild | RollupWatcher;
|
||||
|
||||
public constructor(
|
||||
buildDir: string,
|
||||
publicDir: string,
|
||||
devWatchedViewPath: string,
|
||||
...additionalViewPaths: string[]
|
||||
) {
|
||||
super(buildDir, publicDir, devWatchedViewPath, ...additionalViewPaths);
|
||||
}
|
||||
|
||||
public getExtension(): string {
|
||||
return 'svelte';
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: add replaces on ssr html
|
||||
*/
|
||||
public async render(
|
||||
file: string,
|
||||
locals: Record<string, unknown>,
|
||||
callback: (err: Error | null, output?: string) => void,
|
||||
): Promise<void> {
|
||||
const canonicalViewName = this.toCanonicalName(file);
|
||||
|
||||
// View
|
||||
const actualFile = path.join(this.getBuildDir(), canonicalViewName + COMPILED_SVELTE_EXTENSION);
|
||||
const view = await this.fileCache.get(actualFile, config.get<boolean>('view.dev'));
|
||||
|
||||
// Root template
|
||||
const templateFile = await this.resolveFileFromCanonicalName('layouts/svelte_layout.html');
|
||||
let output = await this.fileCache.get(templateFile, config.get<boolean>('view.dev'));
|
||||
|
||||
// Pre-compiled parts
|
||||
const [
|
||||
backendLines,
|
||||
head,
|
||||
html,
|
||||
css,
|
||||
] = view.split(SvelteViewEngine.getPreCompileSeparator(canonicalViewName));
|
||||
|
||||
// Props (locals)
|
||||
locals = Object.assign(this.getGlobals(), locals);
|
||||
|
||||
const localMap: Record<string, unknown> = {};
|
||||
backendLines.split('\n').forEach(line => {
|
||||
const key = line.substring(1, line.indexOf(',') >= 0 ? line.indexOf(',') - 1 : line.length - 1);
|
||||
if (line.indexOf('[') >= 0) {
|
||||
const args = line.substring(line.indexOf('[') + 1, line.length - 1)
|
||||
.split(/, *?/)
|
||||
.map(arg => {
|
||||
if (arg.startsWith("'")) return '"' + arg.substring(1, arg.length - 1) + '"';
|
||||
return arg;
|
||||
})
|
||||
.map(arg => JSON.parse(arg));
|
||||
|
||||
const f = locals[key];
|
||||
if (typeof f !== 'function') throw new Error(key + ' is not a function.');
|
||||
localMap[`'${key}', ${JSON.stringify(args)}`] = f.call(locals, ...args);
|
||||
} else {
|
||||
localMap[`'${key}'`] = locals[key];
|
||||
}
|
||||
});
|
||||
const props = JSON.stringify(localMap);
|
||||
|
||||
// Replaces
|
||||
output = output.replace('%canonicalViewName%', canonicalViewName);
|
||||
output = output.replace('%props%', props);
|
||||
output = output.replace('%head%', head);
|
||||
output = output.replace('%html%', html);
|
||||
output = output.replace('%css%', css);
|
||||
|
||||
callback(null, output);
|
||||
}
|
||||
|
||||
public async end(): Promise<void> {
|
||||
await this.stopRollup();
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: add a way to add globals from anywhere
|
||||
*/
|
||||
private getGlobals(): Record<string, unknown> {
|
||||
return {
|
||||
route: (name: string) => 'unimplemented route ' + name,
|
||||
direct: 'access',
|
||||
};
|
||||
}
|
||||
|
||||
public async preCompile(canonicalName: string, alsoCompileDependents: boolean): Promise<void> {
|
||||
const file = await this.resolveFileFromCanonicalName(canonicalName);
|
||||
const intermediateFile = path.join(this.getBuildDir(), canonicalName);
|
||||
|
||||
logger.debug(canonicalName + ' > ', 'Pre-compiling', file, '->', intermediateFile);
|
||||
const source = await this.fileCache.get(file, config.get<boolean>('view.dev'));
|
||||
|
||||
const allBackendLines: string[] = [];
|
||||
for (const dependency of this.resolveDependencies(source, canonicalName)) {
|
||||
allBackendLines.push(...(await this.replaceBackendCode(dependency)).backendLines);
|
||||
}
|
||||
|
||||
const {backendReplacedCode, backendLines} = await this.replaceBackendCode(canonicalName, source);
|
||||
allBackendLines.push(...backendLines);
|
||||
|
||||
// Server Side Render (initial HTML and CSS, no-js)
|
||||
const ssr = await this.compileSsr(canonicalName, intermediateFile, backendReplacedCode);
|
||||
|
||||
const separator = SvelteViewEngine.getPreCompileSeparator(canonicalName);
|
||||
const finalCode = [
|
||||
[...new Set<string>(allBackendLines).values()].join('\n'),
|
||||
ssr.head,
|
||||
ssr.html,
|
||||
ssr.css.code,
|
||||
].join(separator);
|
||||
|
||||
const swafViewFile = path.join(this.getBuildDir(), canonicalName + COMPILED_SVELTE_EXTENSION);
|
||||
await afs.mkdir(path.dirname(swafViewFile), {recursive: true});
|
||||
await afs.writeFile(swafViewFile, finalCode);
|
||||
|
||||
if (alsoCompileDependents && Object.keys(this.dependencyCache).indexOf(canonicalName) >= 0) {
|
||||
logger.debug(canonicalName + ' > ', 'Compiling dependents...');
|
||||
for (const dependent of [...this.dependencyCache[canonicalName]]) {
|
||||
await this.preCompile(dependent, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private resolveDependencies(source: string, canonicalViewName: string): string[] {
|
||||
const dependencies: string[] = [];
|
||||
|
||||
for (const match of source.matchAll(/import .+ from ['"](.+?\.svelte)['"];/gm)) {
|
||||
dependencies.push(path.join(path.dirname(canonicalViewName), match[1]));
|
||||
}
|
||||
|
||||
// Clear existing links from cache
|
||||
for (const dependency of Object.keys(this.dependencyCache)) {
|
||||
this.dependencyCache[dependency].delete(canonicalViewName);
|
||||
}
|
||||
|
||||
// Add new links to cache
|
||||
for (const dependency of dependencies) {
|
||||
if (Object.keys(this.dependencyCache).indexOf(dependency) < 0) {
|
||||
this.dependencyCache[dependency] = new Set<string>();
|
||||
}
|
||||
this.dependencyCache[dependency].add(canonicalViewName);
|
||||
}
|
||||
|
||||
return dependencies;
|
||||
}
|
||||
|
||||
private async replaceBackendCode(canonicalViewName: string, code?: string): Promise<{
|
||||
backendReplacedCode: string,
|
||||
backendLines: string[],
|
||||
}> {
|
||||
// Cache
|
||||
if (Object.keys(this.backendCodeCache).indexOf(canonicalViewName) >= 0) {
|
||||
return this.backendCodeCache[canonicalViewName];
|
||||
}
|
||||
|
||||
// mkdir output file dir
|
||||
const outputFile = path.join(this.getBuildDir(), canonicalViewName);
|
||||
await afs.mkdir(path.dirname(outputFile), {recursive: true});
|
||||
|
||||
// Read source file if code was not already provided
|
||||
if (!code) {
|
||||
const file = await this.resolveFileFromCanonicalName(canonicalViewName);
|
||||
code = await this.fileCache.get(file, config.get<boolean>('view.dev'));
|
||||
}
|
||||
|
||||
// Skip replace if there is no swaf export
|
||||
if (!code.match(/export[ \n]+let[ \n]+swaf[ \n]*=[ \n]*{[ \n]*}/)) {
|
||||
const generated = {
|
||||
backendReplacedCode: code,
|
||||
backendLines: [],
|
||||
};
|
||||
await afs.writeFile(outputFile, generated.backendReplacedCode);
|
||||
this.backendCodeCache[canonicalViewName] = generated;
|
||||
return generated;
|
||||
}
|
||||
|
||||
|
||||
let output = code;
|
||||
const backendLines = new Set<string>();
|
||||
|
||||
let index = 0;
|
||||
while ((index = output.indexOf(BACKEND_CODE_PREFIX, index + 1)) >= 0) {
|
||||
// Escaping
|
||||
if (index > 0 && output[index - 1] === '\\') {
|
||||
const isEscapingEscaped: boolean = index > 1 && output[index - 2] === '\\';
|
||||
output = output.substring(0, index - 1 - (isEscapingEscaped ? 1 : 0)) +
|
||||
output.substring(index, output.length);
|
||||
continue;
|
||||
}
|
||||
|
||||
const startIndex = index + BACKEND_CODE_PREFIX.length;
|
||||
let endIndex = startIndex;
|
||||
let struct = 0;
|
||||
|
||||
while (endIndex < output.length) {
|
||||
if (['(', '[', '{'].indexOf(output[endIndex]) >= 0) struct++;
|
||||
if ([')', ']', '}'].indexOf(output[endIndex]) >= 0) {
|
||||
struct--;
|
||||
if (struct <= 0) {
|
||||
if (struct === 0) endIndex++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ([' ', '\n', '<'].indexOf(output[endIndex]) >= 0 && struct === 0) break;
|
||||
endIndex++;
|
||||
}
|
||||
|
||||
let backendLine = output.substring(startIndex, endIndex);
|
||||
if (backendLine.match(/([^()]+)\((.+?)\)/)) {
|
||||
backendLine = backendLine.replace(/([^()]+)\((.+?)\)/, "'$1', [$2]");
|
||||
} else {
|
||||
backendLine = backendLine.replace(/([^()]+)/, "'$1'");
|
||||
}
|
||||
|
||||
backendLines.add(backendLine);
|
||||
output = output.substring(0, index) +
|
||||
'swaf(' + backendLine + ')' +
|
||||
output.substring(endIndex, output.length);
|
||||
}
|
||||
|
||||
const generated = {
|
||||
backendReplacedCode: output,
|
||||
backendLines: [...backendLines],
|
||||
};
|
||||
await afs.writeFile(outputFile, generated.backendReplacedCode);
|
||||
this.backendCodeCache[canonicalViewName] = generated;
|
||||
|
||||
return generated;
|
||||
}
|
||||
|
||||
public async afterPreCompile(): Promise<void> {
|
||||
await this.bundle(...Object.keys(this.backendCodeCache));
|
||||
}
|
||||
|
||||
|
||||
public async onNewFile(): Promise<void> {
|
||||
await this.stopRollup();
|
||||
}
|
||||
|
||||
public async onFileChange(file: string): Promise<void> {
|
||||
delete this.backendCodeCache[this.toCanonicalName(file)];
|
||||
}
|
||||
|
||||
private async compileSsr(canonicalName: string, file: string, code: string): Promise<{
|
||||
head: string,
|
||||
css: CssResult,
|
||||
html: string,
|
||||
}> {
|
||||
// Svelte preprocess
|
||||
logger.debug(canonicalName + ' > ', 'Preprocessing svelte', file);
|
||||
const preprocessed = await preprocess(
|
||||
code,
|
||||
sveltePreprocess({
|
||||
typescript: {
|
||||
tsconfigFile: 'tsconfig.views.json',
|
||||
},
|
||||
}),
|
||||
{
|
||||
filename: file,
|
||||
},
|
||||
);
|
||||
|
||||
// Svelte compile
|
||||
logger.debug(canonicalName + ' > ', 'Compiling svelte ssr', file);
|
||||
const svelteSsr = compile(preprocessed.code, {
|
||||
dev: config.get<boolean>('view.dev'),
|
||||
generate: 'ssr',
|
||||
format: 'cjs',
|
||||
cssOutputFilename: file + '.css',
|
||||
});
|
||||
|
||||
const locals = this.getGlobals();
|
||||
return requireFromString(svelteSsr.js.code, file).default.render({
|
||||
swaf: (key: string, args?: unknown[]) => {
|
||||
if (!args) return locals[key];
|
||||
|
||||
const f = locals[key];
|
||||
if (typeof f !== 'function') throw new Error(key + ' is not a function.');
|
||||
return f.call(locals, ...args);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: load rollup config from external source
|
||||
*/
|
||||
private async bundle(...canonicalViewNames: string[]): Promise<void> {
|
||||
logger.debug('Bundling');
|
||||
|
||||
const production = !config.get<boolean>('view.dev');
|
||||
const options: RollupOptions | RollupWatchOptions = {
|
||||
input: canonicalViewNames.map(name => path.join(this.getBuildDir(), name)),
|
||||
output: {
|
||||
sourcemap: true,
|
||||
format: 'es',
|
||||
name: 'bundle',
|
||||
dir: path.join(this.getPublicDir(), 'js'),
|
||||
entryFileNames: (chunkInfo: PreRenderedChunk): string => {
|
||||
const name = chunkInfo.facadeModuleId ?
|
||||
path.relative(this.getBuildDir(), chunkInfo.facadeModuleId) :
|
||||
chunkInfo.name;
|
||||
return name + '.js';
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
svelte({
|
||||
preprocess: sveltePreprocess({
|
||||
typescript: {
|
||||
tsconfigFile: 'tsconfig.views.json',
|
||||
},
|
||||
}),
|
||||
compilerOptions: {
|
||||
// enable run-time checks when not in production
|
||||
dev: !production,
|
||||
hydratable: true,
|
||||
},
|
||||
}),
|
||||
// we'll extract any component CSS out into
|
||||
// a separate file - better for performance
|
||||
cssOnlyRollupPlugin({output: 'bundle.css'}),
|
||||
|
||||
// If you have external dependencies installed from
|
||||
// npm, you'll most likely need these plugins. In
|
||||
// some cases you'll need additional configuration -
|
||||
// consult the documentation for details:
|
||||
// https://github.com/rollup/plugins/tree/master/packages/commonjs
|
||||
resolve({
|
||||
browser: true,
|
||||
dedupe: ['svelte'],
|
||||
}),
|
||||
commonjs(),
|
||||
],
|
||||
watch: {
|
||||
buildDelay: 1000,
|
||||
clearScreen: false,
|
||||
},
|
||||
};
|
||||
|
||||
if (production) {
|
||||
// If we're building for production (npm run build
|
||||
// instead of npm run dev), minify
|
||||
options.plugins?.push(terser());
|
||||
} else {
|
||||
// Watch the `public` directory and refresh the
|
||||
// browser on changes when not in production
|
||||
const plugin = livereloadRollupPlugin('public');
|
||||
options.plugins?.push(plugin);
|
||||
}
|
||||
|
||||
for (const name of canonicalViewNames) {
|
||||
await afs.mkdir(path.dirname(path.join(this.getPublicDir(), 'js', name)), {recursive: true});
|
||||
}
|
||||
|
||||
if (production) {
|
||||
if (!this.rollup) {
|
||||
this.rollup = await rollup(options);
|
||||
|
||||
await this.rollup.write({
|
||||
format: 'es',
|
||||
dir: path.join(this.getPublicDir(), 'js'),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (!this.rollup) {
|
||||
this.rollup = watch(options);
|
||||
this.rollup.on('event', (event) => {
|
||||
if (event.code === 'ERROR' || event.code === 'BUNDLE_END') {
|
||||
event.result?.close().catch(err => logger.error(err));
|
||||
logger.debug('Bundled from watch');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async stopRollup(): Promise<void> {
|
||||
if (this.rollup) {
|
||||
logger.debug('Stopping rollup...');
|
||||
await this.rollup.close();
|
||||
this.rollup = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
142
src/frontend/ViewEngine.ts
Normal file
142
src/frontend/ViewEngine.ts
Normal file
@ -0,0 +1,142 @@
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import chokidar from "chokidar";
|
||||
import {logger} from "../Logger";
|
||||
import {afs, readdirRecursively} from "../Utils";
|
||||
|
||||
export default abstract class ViewEngine {
|
||||
protected readonly viewPaths: string[];
|
||||
|
||||
/**
|
||||
* @param buildDir A temporary directory that will contain any non-final or final non-public asset.
|
||||
* @param publicDir The output directory that should contain all final and public assets.
|
||||
* @param devWatchedViewDir The directory that should be watched in dev environment.
|
||||
* @param additionalViewPaths By order of priority, the directories that contain all the views of the app.
|
||||
* Swaf provided views and default ./view directory are automatically added (don't add them yourself).
|
||||
* @protected
|
||||
*/
|
||||
protected constructor(
|
||||
private readonly buildDir: string,
|
||||
private readonly publicDir: string,
|
||||
private readonly devWatchedViewDir: string,
|
||||
...additionalViewPaths: string[]
|
||||
) {
|
||||
this.viewPaths = [
|
||||
...additionalViewPaths.map(p => path.resolve(p)),
|
||||
path.resolve(__dirname, '../../../views'),
|
||||
path.resolve(__dirname, '../../views'),
|
||||
path.resolve(__dirname, '../views'),
|
||||
].filter(dir => fs.existsSync(dir));
|
||||
|
||||
if (!fs.existsSync(this.buildDir)) {
|
||||
fs.mkdirSync(this.buildDir, {recursive: true});
|
||||
}
|
||||
}
|
||||
|
||||
public abstract getExtension(): string;
|
||||
|
||||
public abstract render(
|
||||
file: string,
|
||||
locals: Record<string, unknown>,
|
||||
callback: (err: Error | null, output?: string) => void,
|
||||
): Promise<void>;
|
||||
|
||||
public end?(): Promise<void>;
|
||||
|
||||
public getViewPaths(): string[] {
|
||||
return this.viewPaths;
|
||||
}
|
||||
|
||||
public getBuildDir(): string {
|
||||
return this.buildDir;
|
||||
}
|
||||
|
||||
public getPublicDir(): string {
|
||||
return this.publicDir;
|
||||
}
|
||||
|
||||
public getDevWatchedViewDir(): string {
|
||||
return this.devWatchedViewDir;
|
||||
}
|
||||
|
||||
public preCompile?(canonicalName: string, alsoCompileDependents: boolean): Promise<void>;
|
||||
|
||||
public afterPreCompile?(): Promise<void>;
|
||||
|
||||
public async preCompileAll(): Promise<void> {
|
||||
if (this.preCompile) {
|
||||
logger.info(`Pre-compiling ${this.getExtension()} views...`);
|
||||
|
||||
// List all views
|
||||
const views: string[] = [];
|
||||
for (const viewPath of this.viewPaths) {
|
||||
await readdirRecursively(viewPath, async file => {
|
||||
if (file.endsWith('.' + this.getExtension())) {
|
||||
views.push(this.toCanonicalName(file));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Deduplicate and pre-compile
|
||||
for (const canonicalName of [...new Set<string>(views)]) {
|
||||
await this.preCompile(canonicalName, false);
|
||||
}
|
||||
}
|
||||
|
||||
await this.afterPreCompile?.();
|
||||
}
|
||||
|
||||
public onNewFile?(file: string): Promise<void>;
|
||||
|
||||
public onFileChange?(file: string): Promise<void>;
|
||||
|
||||
public async watch(): Promise<void> {
|
||||
const watcher = chokidar.watch(this.devWatchedViewDir, {persistent: true});
|
||||
watcher.on('ready', () => {
|
||||
logger.debug(`Watching ${this.getExtension()} assets for changes`);
|
||||
|
||||
watcher.on('add', (file) => {
|
||||
if (file.endsWith('.' + this.getExtension())) {
|
||||
(this.onNewFile ? this.onNewFile(file) : Promise.resolve())
|
||||
.then(() => this.preCompile?.(this.toCanonicalName(file), true))
|
||||
.then(() => this.afterPreCompile?.())
|
||||
.catch(err => logger.error(err));
|
||||
}
|
||||
});
|
||||
|
||||
watcher.on('change', (file) => {
|
||||
if (file.endsWith('.' + this.getExtension())) {
|
||||
(this.onFileChange ? this.onFileChange(file) : Promise.resolve())
|
||||
.then(() => this.preCompile?.(this.toCanonicalName(file), true))
|
||||
.then(() => this.afterPreCompile?.())
|
||||
.catch(err => logger.error(err));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
protected toCanonicalName(file: string): string {
|
||||
const resolvedFilePath = path.resolve(file);
|
||||
|
||||
let canonicalViewName: string | null = null;
|
||||
for (const viewPath of this.viewPaths) {
|
||||
if (resolvedFilePath.startsWith(viewPath)) {
|
||||
canonicalViewName = resolvedFilePath.substring(viewPath.length + 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (!canonicalViewName) throw new Error('view ' + file + ' not found');
|
||||
return canonicalViewName;
|
||||
}
|
||||
|
||||
protected async resolveFileFromCanonicalName(canonicalName: string): Promise<string> {
|
||||
for (const viewPath of this.viewPaths) {
|
||||
const tryPath = path.join(viewPath, canonicalName);
|
||||
if (await afs.exists(tryPath)) {
|
||||
return tryPath;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('View not found from canonical name ' + canonicalName);
|
||||
}
|
||||
}
|
35
src/utils/FileCache.ts
Normal file
35
src/utils/FileCache.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import fs from "fs";
|
||||
import {afs} from "../Utils";
|
||||
|
||||
export default class FileCache {
|
||||
private readonly cache: Record<string, string> = {};
|
||||
|
||||
public async get(file: string, forceRead: boolean = false): Promise<string> {
|
||||
if (forceRead || !this.cache[file]) {
|
||||
await this.load(file);
|
||||
}
|
||||
|
||||
return this.cache[file];
|
||||
}
|
||||
|
||||
public getSync(file: string, forceRead: boolean = false): string {
|
||||
if (forceRead || !this.cache[file]) {
|
||||
this.loadSync(file);
|
||||
}
|
||||
|
||||
return this.cache[file];
|
||||
}
|
||||
|
||||
public getOrFail(file: string): string {
|
||||
if (!this.cache[file]) throw new Error(file + ' not cached.');
|
||||
return this.cache[file];
|
||||
}
|
||||
|
||||
public async load(file: string): Promise<void> {
|
||||
this.cache[file] = (await afs.readFile(file)).toString();
|
||||
}
|
||||
|
||||
public loadSync(file: string): void {
|
||||
this.cache[file] = fs.readFileSync(file).toString();
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user