swaf/src/frontend/SvelteViewEngine.ts

302 lines
12 KiB
TypeScript

import "../../node_modules/svelte/register.js";
import config from "config";
import crypto from "crypto";
import {promises as fs} from 'fs';
import {nanoid} from "nanoid";
import path from "path";
import requireFromString from "require-from-string";
import {compile, preprocess} from "svelte/compiler";
import {CssResult} from "svelte/types/compiler/interfaces";
import {sveltePreprocess} from "svelte-preprocess/dist/autoProcess.js";
import {logger} from "../Logger.js";
import FileCache from "../utils/FileCache.js";
import ViewEngine from "./ViewEngine.js";
const BACKEND_CODE_PREFIX = 'locals.';
const BACKEND_CODE_PREFIX_TEMPORARY_HOLDER = '$$locals$$';
const COMPILED_SVELTE_EXTENSION = '.precompiled';
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 backendCallsCache: Record<string, {
replacedBackendCall: string,
backendCalls: string[],
}> = {};
public constructor(
targetDir: string,
...additionalViewPaths: string[]
) {
super(targetDir, 'views', 'svelte', true, ...additionalViewPaths);
}
public async onFileChange(file: string): Promise<void> {
delete this.backendCallsCache[this.toCanonicalName(file)];
}
public async onFileRemove(file: string): Promise<void> {
const canonicalName = this.toCanonicalName(file);
delete this.backendCallsCache[canonicalName];
delete this.dependencyCache[canonicalName];
Object.values(this.dependencyCache).forEach(set => set.delete(canonicalName));
await super.onFileRemove(file);
}
public async render(
file: string,
locals: Record<string, unknown>,
): Promise<string> {
const canonicalViewName = this.toCanonicalName(file);
// View
const actualFile = path.join(this.targetDir, canonicalViewName + COMPILED_SVELTE_EXTENSION);
const view = await this.fileCache.get(actualFile, !config.get<boolean>('view.cache'));
// Root template
const templateFile = await this.resolveFileFromCanonicalName('layouts/svelte_layout.html');
const rawOutput = await this.fileCache.get(templateFile, !config.get<boolean>('view.cache'));
// Pre-compiled parts
const [
backendCalls,
head,
html,
css,
] = view.split(SvelteViewEngine.getPreCompileSeparator(canonicalViewName));
const localMap: Record<string, unknown> = {};
backendCalls.split('\n').forEach(code => {
const key = code.substring(1, code.indexOf(',') >= 0 ? code.indexOf(',') - 1 : code.length - 1);
if (code.indexOf('`[') >= 0) {
const args = code.substring(code.indexOf('`[') + 2, code.length - 2)
.split(/, *?/)
.map(arg => {
if (arg.startsWith("'")) return '"' + arg.substring(1, arg.length - 1) + '"';
return arg;
})
.map(arg => Function(`"use strict";const locals = arguments[0];return (${arg});`)(locals)); // Uses named parameter locals
const f = locals[key];
if (typeof f !== 'function') throw new Error(key + ' is not a function.');
localMap[`'${key}', \`[${code.substring(code.indexOf('`[') + 2, code.length - 2)}]\``] = f.call(locals, ...args);
} else {
localMap[`'${key}'`] = locals[key];
}
});
const actualLocals = JSON.stringify(localMap, (key, value) => {
return typeof value === 'function' ?
value.toString() :
value;
});
// Replaces
const replaceMap: Record<string, string> = {
canonicalViewName: canonicalViewName,
locals: actualLocals,
head: head,
html: html,
css: css,
};
return rawOutput.replace(
new RegExp(Object.keys(replaceMap).map(str => `%${str}%`).join('|'), 'g'),
(substring) => replaceMap[substring.slice(1, substring.length - 1)],
);
}
public async preCompile(canonicalName: string, alsoCompileDependents: boolean): Promise<void> {
const file = await this.resolveFileFromCanonicalName(canonicalName);
const intermediateFile = path.join(this.targetDir, canonicalName);
logger.info(canonicalName + ' > ', 'Pre-compiling', file, '->', intermediateFile);
const source = await this.fileCache.get(file, !config.get<boolean>('view.cache'));
const allBackendCalls: string[] = [];
for (const dependency of this.resolveDependencies(source, canonicalName)) {
allBackendCalls.push(...(await this.replaceBackendCalls(dependency)).backendCalls);
}
const {replacedBackendCall, backendCalls} = await this.replaceBackendCalls(canonicalName, source);
allBackendCalls.push(...backendCalls);
// Server Side Render (initial HTML and CSS, no-js)
const ssr = await this.compileSsr(canonicalName, intermediateFile, replacedBackendCall);
const separator = SvelteViewEngine.getPreCompileSeparator(canonicalName);
const finalCode = [
[...new Set<string>(allBackendCalls).values()].join('\n'),
ssr.head,
ssr.html,
ssr.css.code,
].join(separator);
const swafViewFile = path.join(this.targetDir, canonicalName + COMPILED_SVELTE_EXTENSION);
await fs.mkdir(path.dirname(swafViewFile), {recursive: true});
await fs.writeFile(swafViewFile, finalCode);
if (alsoCompileDependents && Object.keys(this.dependencyCache).indexOf(canonicalName) >= 0) {
logger.info(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 replaceBackendCalls(canonicalViewName: string, code?: string): Promise<{
replacedBackendCall: string,
backendCalls: string[],
}> {
// Cache
if (Object.keys(this.backendCallsCache).indexOf(canonicalViewName) >= 0) {
return this.backendCallsCache[canonicalViewName];
}
// mkdir output file dir
const outputFile = path.join(this.targetDir, canonicalViewName);
await fs.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.cache'));
}
// Skip replace if there is no swaf export
if (!code.match(/export[ \n]+let[ \n]+locals[ \n]*=[ \n]*{[ \n]*}/)) {
const generated = {
replacedBackendCall: code,
backendCalls: [],
};
await fs.writeFile(outputFile, generated.replacedBackendCall);
this.backendCallsCache[canonicalViewName] = generated;
return generated;
}
let output = code;
const backendCalls = 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 backendCall = output.substring(startIndex, endIndex);
if (backendCall.match(/([^()]+)\((.+?)\)/)) {
backendCall = backendCall.replace(/([^()]+)\((.+?)\)/, "'$1', `[$2]`");
} else {
backendCall = backendCall.replace(/([^()]+)(\(\))?/, "'$1'");
}
backendCalls.add(backendCall);
output = output.substring(0, index) +
'locals(' + backendCall.split(BACKEND_CODE_PREFIX).join(BACKEND_CODE_PREFIX_TEMPORARY_HOLDER) + ')' +
output.substring(endIndex, output.length);
}
output = output.split(BACKEND_CODE_PREFIX_TEMPORARY_HOLDER).join(BACKEND_CODE_PREFIX);
const generated = {
replacedBackendCall: output,
backendCalls: [...backendCalls],
};
await fs.writeFile(outputFile, generated.replacedBackendCall);
this.backendCallsCache[canonicalViewName] = generated;
return generated;
}
private async compileSsr(canonicalName: string, file: string, code: string): Promise<{
head: string,
css: CssResult,
html: string,
}> {
// Svelte preprocess
logger.info(canonicalName + ' > ', 'Preprocessing svelte', file);
const preprocessed = await preprocess(
code,
sveltePreprocess({
typescript: {
tsconfigFile: 'tsconfig.svelte.json',
},
}),
{
filename: file,
},
);
// Svelte compile
logger.info(canonicalName + ' > ', 'Compiling svelte ssr', file);
const svelteSsr = compile(preprocessed.code, {
dev: config.get<boolean>('view.dev'),
generate: 'ssr',
format: 'cjs',
cssOutputFilename: file + '.css',
});
// Load locals into locals store
const localsModulePath = "../../build/ts/stores.js";
const localsModule = await import(localsModulePath);
localsModule.locals.set(ViewEngine.getGlobals());
// Load module and render
return requireFromString(svelteSsr.js.code, file + nanoid() + '.js').default.render();
}
}