2021-03-24 21:41:13 +01:00
|
|
|
import crypto from "crypto";
|
|
|
|
import path from "path";
|
|
|
|
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";
|
2021-03-26 10:56:32 +01:00
|
|
|
import * as child_process from "child_process";
|
2021-04-28 14:07:11 +02:00
|
|
|
import fs from "fs";
|
2021-03-24 21:41:13 +01:00
|
|
|
|
|
|
|
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[],
|
|
|
|
}> = {};
|
|
|
|
|
2021-03-26 10:56:32 +01:00
|
|
|
private rollup?: child_process.ChildProcess;
|
2021-03-24 21:41:13 +01:00
|
|
|
|
2021-04-28 14:07:11 +02:00
|
|
|
/**
|
|
|
|
* @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 see {@link ViewEngine}.
|
|
|
|
* @param additionalViewPaths see {@link ViewEngine}.
|
|
|
|
*/
|
2021-03-24 21:41:13 +01:00
|
|
|
public constructor(
|
2021-04-28 14:07:11 +02:00
|
|
|
private readonly buildDir: string,
|
|
|
|
private readonly publicDir: string,
|
|
|
|
devWatchedViewDir: string,
|
2021-03-24 21:41:13 +01:00
|
|
|
...additionalViewPaths: string[]
|
|
|
|
) {
|
2021-04-28 14:07:11 +02:00
|
|
|
super(devWatchedViewDir, ...additionalViewPaths);
|
|
|
|
|
|
|
|
if (!fs.existsSync(this.buildDir)) {
|
|
|
|
fs.mkdirSync(this.buildDir, {recursive: true});
|
|
|
|
}
|
2021-03-24 21:41:13 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
public getExtension(): string {
|
|
|
|
return 'svelte';
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* TODO: add replaces on ssr html
|
|
|
|
*/
|
|
|
|
public async render(
|
|
|
|
file: string,
|
|
|
|
locals: Record<string, unknown>,
|
2021-04-28 14:53:46 +02:00
|
|
|
): Promise<string> {
|
2021-03-24 21:41:13 +01:00
|
|
|
const canonicalViewName = this.toCanonicalName(file);
|
|
|
|
|
|
|
|
// View
|
2021-04-28 14:07:11 +02:00
|
|
|
const actualFile = path.join(this.buildDir, canonicalViewName + COMPILED_SVELTE_EXTENSION);
|
2021-04-27 15:08:41 +02:00
|
|
|
const view = await this.fileCache.get(actualFile, !config.get<boolean>('view.cache'));
|
2021-03-24 21:41:13 +01:00
|
|
|
|
|
|
|
// Root template
|
|
|
|
const templateFile = await this.resolveFileFromCanonicalName('layouts/svelte_layout.html');
|
2021-04-28 11:00:51 +02:00
|
|
|
const rawOutput = await this.fileCache.get(templateFile, !config.get<boolean>('view.cache'));
|
2021-03-24 21:41:13 +01:00
|
|
|
|
|
|
|
// Pre-compiled parts
|
|
|
|
const [
|
|
|
|
backendLines,
|
|
|
|
head,
|
|
|
|
html,
|
|
|
|
css,
|
|
|
|
] = view.split(SvelteViewEngine.getPreCompileSeparator(canonicalViewName));
|
|
|
|
|
|
|
|
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
|
2021-04-28 11:00:51 +02:00
|
|
|
const replaces: { [key: string]: string } = {
|
|
|
|
canonicalViewName: canonicalViewName,
|
|
|
|
props: props,
|
|
|
|
head: head,
|
|
|
|
html: html,
|
|
|
|
css: css,
|
|
|
|
};
|
|
|
|
const replaceOperations: Record<number, {
|
|
|
|
key: string;
|
|
|
|
replaceValue: string;
|
|
|
|
} | undefined> = {};
|
|
|
|
for (const entry of Object.entries(replaces)) {
|
|
|
|
const matches = rawOutput.matchAll(new RegExp(`%${entry[0]}%`, 'g'));
|
|
|
|
for (const match of matches) {
|
|
|
|
if (typeof match.index === 'number') {
|
|
|
|
replaceOperations[match.index] = {
|
|
|
|
key: `%${entry[0]}%`,
|
|
|
|
replaceValue: entry[1],
|
|
|
|
};
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let output = '';
|
|
|
|
for (let i = 0; i < rawOutput.length; i++) {
|
|
|
|
const replaceOperation = replaceOperations[i];
|
|
|
|
if (replaceOperation) {
|
|
|
|
output += replaceOperation.replaceValue;
|
|
|
|
i += replaceOperation.key.length - 1;
|
|
|
|
} else {
|
|
|
|
output += rawOutput[i];
|
|
|
|
}
|
|
|
|
}
|
2021-03-24 21:41:13 +01:00
|
|
|
|
2021-04-28 14:53:46 +02:00
|
|
|
return output;
|
2021-03-24 21:41:13 +01:00
|
|
|
}
|
|
|
|
|
2021-03-26 10:56:32 +01:00
|
|
|
public async stop(): Promise<void> {
|
|
|
|
await super.stop();
|
2021-03-24 21:41:13 +01:00
|
|
|
await this.stopRollup();
|
|
|
|
}
|
|
|
|
|
|
|
|
public async preCompile(canonicalName: string, alsoCompileDependents: boolean): Promise<void> {
|
|
|
|
const file = await this.resolveFileFromCanonicalName(canonicalName);
|
2021-04-28 14:07:11 +02:00
|
|
|
const intermediateFile = path.join(this.buildDir, canonicalName);
|
2021-03-24 21:41:13 +01:00
|
|
|
|
2021-03-26 10:56:32 +01:00
|
|
|
logger.info(canonicalName + ' > ', 'Pre-compiling', file, '->', intermediateFile);
|
2021-04-27 15:08:41 +02:00
|
|
|
const source = await this.fileCache.get(file, !config.get<boolean>('view.cache'));
|
2021-03-24 21:41:13 +01:00
|
|
|
|
|
|
|
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);
|
|
|
|
|
2021-04-28 14:07:11 +02:00
|
|
|
const swafViewFile = path.join(this.buildDir, canonicalName + COMPILED_SVELTE_EXTENSION);
|
2021-03-24 21:41:13 +01:00
|
|
|
await afs.mkdir(path.dirname(swafViewFile), {recursive: true});
|
|
|
|
await afs.writeFile(swafViewFile, finalCode);
|
|
|
|
|
|
|
|
if (alsoCompileDependents && Object.keys(this.dependencyCache).indexOf(canonicalName) >= 0) {
|
2021-03-26 10:56:32 +01:00
|
|
|
logger.info(canonicalName + ' > ', 'Compiling dependents...');
|
2021-03-24 21:41:13 +01:00
|
|
|
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
|
2021-04-28 14:07:11 +02:00
|
|
|
const outputFile = path.join(this.buildDir, canonicalViewName);
|
2021-03-24 21:41:13 +01:00
|
|
|
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);
|
2021-04-27 15:08:41 +02:00
|
|
|
code = await this.fileCache.get(file, !config.get<boolean>('view.cache'));
|
2021-03-24 21:41:13 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
}
|
|
|
|
|
2021-03-26 10:56:32 +01:00
|
|
|
public async afterPreCompile(watch: boolean): Promise<void> {
|
|
|
|
await this.bundle(watch, ...Object.keys(this.backendCodeCache));
|
2021-03-24 21:41:13 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public async onNewFile(): Promise<void> {
|
|
|
|
await this.stopRollup();
|
|
|
|
}
|
|
|
|
|
|
|
|
public async onFileChange(file: string): Promise<void> {
|
|
|
|
delete this.backendCodeCache[this.toCanonicalName(file)];
|
|
|
|
}
|
|
|
|
|
2021-03-26 10:56:32 +01:00
|
|
|
public async onFileRemove(file: string): Promise<void> {
|
|
|
|
const canonicalName = this.toCanonicalName(file);
|
|
|
|
delete this.backendCodeCache[canonicalName];
|
|
|
|
delete this.dependencyCache[canonicalName];
|
|
|
|
Object.values(this.dependencyCache).forEach(set => set.delete(canonicalName));
|
|
|
|
await this.stopRollup();
|
|
|
|
await this.afterPreCompile(true);
|
|
|
|
}
|
|
|
|
|
2021-03-24 21:41:13 +01:00
|
|
|
private async compileSsr(canonicalName: string, file: string, code: string): Promise<{
|
|
|
|
head: string,
|
|
|
|
css: CssResult,
|
|
|
|
html: string,
|
|
|
|
}> {
|
|
|
|
// Svelte preprocess
|
2021-03-26 10:56:32 +01:00
|
|
|
logger.info(canonicalName + ' > ', 'Preprocessing svelte', file);
|
2021-03-24 21:41:13 +01:00
|
|
|
const preprocessed = await preprocess(
|
|
|
|
code,
|
|
|
|
sveltePreprocess({
|
|
|
|
typescript: {
|
|
|
|
tsconfigFile: 'tsconfig.views.json',
|
|
|
|
},
|
|
|
|
}),
|
|
|
|
{
|
|
|
|
filename: file,
|
|
|
|
},
|
|
|
|
);
|
|
|
|
|
|
|
|
// Svelte compile
|
2021-03-26 10:56:32 +01:00
|
|
|
logger.info(canonicalName + ' > ', 'Compiling svelte ssr', file);
|
2021-03-24 21:41:13 +01:00
|
|
|
const svelteSsr = compile(preprocessed.code, {
|
|
|
|
dev: config.get<boolean>('view.dev'),
|
|
|
|
generate: 'ssr',
|
|
|
|
format: 'cjs',
|
|
|
|
cssOutputFilename: file + '.css',
|
|
|
|
});
|
|
|
|
|
2021-04-28 14:04:56 +02:00
|
|
|
const globals = ViewEngine.getGlobals();
|
2021-04-23 18:20:22 +02:00
|
|
|
delete require.cache[path.resolve(file)];
|
2021-03-24 21:41:13 +01:00
|
|
|
return requireFromString(svelteSsr.js.code, file).default.render({
|
|
|
|
swaf: (key: string, args?: unknown[]) => {
|
2021-04-28 14:04:56 +02:00
|
|
|
if (!args) return globals[key];
|
2021-03-24 21:41:13 +01:00
|
|
|
|
2021-04-28 14:04:56 +02:00
|
|
|
const f = globals[key];
|
2021-03-24 21:41:13 +01:00
|
|
|
if (typeof f !== 'function') throw new Error(key + ' is not a function.');
|
2021-04-28 14:04:56 +02:00
|
|
|
return f.call(globals, ...args);
|
2021-03-24 21:41:13 +01:00
|
|
|
},
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2021-03-26 10:56:32 +01:00
|
|
|
private async bundle(watch: boolean, ...canonicalViewNames: string[]): Promise<void> {
|
|
|
|
logger.info('Bundling...');
|
2021-03-24 21:41:13 +01:00
|
|
|
|
2021-03-26 10:56:32 +01:00
|
|
|
// Prepare output dir
|
2021-03-24 21:41:13 +01:00
|
|
|
for (const name of canonicalViewNames) {
|
2021-04-28 14:07:11 +02:00
|
|
|
await afs.mkdir(path.dirname(path.join(this.publicDir, 'js', name)), {recursive: true});
|
2021-03-24 21:41:13 +01:00
|
|
|
}
|
|
|
|
|
2021-03-26 10:56:32 +01:00
|
|
|
const production = !config.get<boolean>('view.dev');
|
2021-04-28 14:07:11 +02:00
|
|
|
const input = canonicalViewNames.map(name => path.join(this.buildDir, name));
|
2021-03-26 10:56:32 +01:00
|
|
|
|
|
|
|
if (!this.rollup) {
|
|
|
|
const args = [
|
|
|
|
'rollup',
|
|
|
|
'-c', 'rollup.config.js',
|
2021-04-28 14:07:11 +02:00
|
|
|
'--environment', `ENV:${production ? 'production' : 'dev'},BUILD_DIR:${this.buildDir},PUBLIC_DIR:${this.publicDir},INPUT:${input.join(':')}`,
|
2021-03-26 10:56:32 +01:00
|
|
|
];
|
|
|
|
if (watch) args.push('--watch');
|
|
|
|
this.rollup = child_process.spawn('yarn', args, {stdio: [process.stdin, process.stdout, process.stderr]});
|
|
|
|
logger.info('Rollup started');
|
|
|
|
this.rollup.once('exit', () => {
|
|
|
|
logger.info('Rollup stopped');
|
|
|
|
this.rollup = undefined;
|
|
|
|
});
|
2021-03-24 21:41:13 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private async stopRollup(): Promise<void> {
|
|
|
|
if (this.rollup) {
|
2021-03-26 10:56:32 +01:00
|
|
|
logger.info(`Stopping rollup (${this.rollup.pid})...`);
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
|
|
if (!this.rollup) return resolve();
|
|
|
|
this.rollup.once('exit', () => {
|
|
|
|
resolve();
|
|
|
|
});
|
|
|
|
if (!this.rollup.kill("SIGTERM")) reject('Could not stop rollup.');
|
|
|
|
});
|
2021-03-24 21:41:13 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|