swaf/src/frontend/SvelteViewEngine.ts

211 lines
7.8 KiB
TypeScript

import "./register_svelte/register_svelte.js";
import clearModule from "clear-module";
import config from "config";
import {promises as fs} from 'fs';
import path from "path";
import requireFromString from "require-from-string";
import {compile, preprocess} from "svelte/compiler";
import {sveltePreprocess} from "svelte-preprocess/dist/autoProcess.js";
import {publicUrl, routes} from "../common/Routing.js";
import {logger} from "../Logger.js";
import FileCache from "../utils/FileCache.js";
import ViewEngine from "./ViewEngine.js";
export default class SvelteViewEngine extends ViewEngine {
private readonly fileCache: FileCache = new FileCache();
private readonly reverseDependencyCache: Record<string, Set<string>> = {};
private readonly preprocessingCache: Record<string, string> = {};
private readonly cssCache: Record<string, string[] | undefined> = {};
public constructor(
targetDir: string,
...additionalViewPaths: string[]
) {
super(targetDir, 'views', 'svelte', true, ...additionalViewPaths);
}
public async onFileChange(file: string): Promise<void> {
delete this.preprocessingCache[this.toCanonicalName(file)];
await super.onFileChange(file);
}
public async onFileRemove(file: string): Promise<void> {
const canonicalName = this.toCanonicalName(file);
delete this.preprocessingCache[canonicalName];
delete this.reverseDependencyCache[canonicalName];
Object.values(this.reverseDependencyCache).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);
const rootTemplateFile = await this.resolveFileFromCanonicalNameOrFail('templates/svelte_template.html');
const rawOutput = await this.fileCache.get(rootTemplateFile, !config.get<boolean>('view.cache'));
locals.isSsr = true;
const {
head,
html,
css,
} = await this.renderSvelteSsr(canonicalViewName, locals);
locals.isSsr = false;
const serializedLocals = JSON.stringify(locals, (key, value) => {
if (key.startsWith('_') || typeof value === 'function') return undefined;
return value;
});
// Replaces
const replaceMap: Record<string, string> = {
canonicalViewName: canonicalViewName,
locals: serializedLocals,
head: head,
html: html,
css: css,
routes: JSON.stringify(routes),
publicUrl: publicUrl,
};
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 targetFile = path.join(this.targetDir, canonicalName);
logger.info(canonicalName + ' > ', 'Pre-compiling', canonicalName, '->', targetFile);
await this.preprocessSvelte(canonicalName);
if (alsoCompileDependents && Object.keys(this.reverseDependencyCache).indexOf(canonicalName) >= 0) {
logger.info(canonicalName + ' > ', 'Pre-compiling dependents...');
for (const dependent of [...this.reverseDependencyCache[canonicalName]]) {
await this.preCompile(dependent, true);
}
}
}
private async preprocessSvelte(canonicalName: string): Promise<string> {
// Cache
if (Object.keys(this.preprocessingCache).indexOf(canonicalName) >= 0) {
return this.preprocessingCache[canonicalName];
}
const file = await this.resolveFileFromCanonicalNameOrFail(canonicalName);
logger.info(canonicalName + ' > ', `Preprocessing ${file}`);
// mkdir output file dir
const outputFile = path.join(this.targetDir, canonicalName);
await fs.mkdir(path.dirname(outputFile), {recursive: true});
// Read source file if code was not already provided
const code = await this.fileCache.get(file, !config.get<boolean>('view.cache'));
// Preprocess svelte
logger.info(canonicalName + ' > ', 'Svelte preprocessing');
const processed = await preprocess(
code,
sveltePreprocess({
typescript: {
tsconfigFile: 'src/assets/views/tsconfig.json',
},
}),
{
filename: outputFile,
},
);
// Write to output file
await fs.writeFile(outputFile, processed.code);
this.resolveAndCacheDependencies(processed.code, canonicalName);
return this.preprocessingCache[canonicalName] = processed.code;
}
private async renderSvelteSsr(canonicalName: string, locals: {[key: string]: unknown}): Promise<{
head: string,
css: string,
html: string,
}> {
const targetFile = path.join(this.targetDir, canonicalName);
const code = await this.fileCache.get(targetFile, !config.get<boolean>('view.cache'));
// Get dependencies css
const dependenciesCss: string[] = [];
for (const dependency of this.resolveAndCacheDependencies(code, canonicalName)) {
if (this.cssCache[dependency] === undefined || !config.get<boolean>('view.cache')) {
await this.renderSvelteSsr(dependency, locals);
}
const css = this.cssCache[dependency];
if (css === undefined) {
logger.error(typeof this.cssCache[dependency], !!this.cssCache[dependency]);
throw new Error(`Compiling ssr of ${dependency} didn't cache its css.`);
}
dependenciesCss.push(...css);
}
logger.info(canonicalName + ' > ', 'Compiling svelte ssr', targetFile);
// Svelte compile
const svelteSsr = compile(code, {
dev: config.get<boolean>('view.dev'),
generate: 'ssr',
format: 'cjs',
cssOutputFilename: targetFile + '.css',
});
// Load locals into locals store
const storesModule = await import(path.resolve(this.targetDir, "../ts/stores.js"));
storesModule.locals.set(locals);
// Load module and render
const moduleId = path.resolve(targetFile);
clearModule.single(moduleId);
const {
head,
css,
html,
} = requireFromString(svelteSsr.js.code, moduleId).default.render();
const cssFragments = css.code === '' ?
dependenciesCss :
[...dependenciesCss, css.code];
this.cssCache[canonicalName] = cssFragments;
return {
head,
css: [...new Set(cssFragments)].join(''),
html,
};
}
private resolveAndCacheDependencies(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.reverseDependencyCache)) {
this.reverseDependencyCache[dependency].delete(canonicalViewName);
}
// Add new links to cache
for (const dependency of dependencies) {
if (Object.keys(this.reverseDependencyCache).indexOf(dependency) < 0) {
this.reverseDependencyCache[dependency] = new Set<string>();
}
this.reverseDependencyCache[dependency].add(canonicalViewName);
}
return dependencies;
}
}