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> = {}; private readonly preprocessingCache: Record = {}; private readonly cssCache: Record = {}; public constructor( targetDir: string, ...additionalViewPaths: string[] ) { super(targetDir, 'views', 'svelte', true, ...additionalViewPaths); } public async onFileChange(file: string): Promise { delete this.preprocessingCache[this.toCanonicalName(file)]; await super.onFileChange(file); } public async onFileRemove(file: string): Promise { 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, ): Promise { const canonicalViewName = this.toCanonicalName(file); const rootTemplateFile = await this.resolveFileFromCanonicalNameOrFail('templates/svelte_template.html'); const rawOutput = await this.fileCache.get(rootTemplateFile, !config.get('view.cache')); const { head, html, css, } = await this.renderSvelteSsr(canonicalViewName, locals); const serializedLocals = JSON.stringify(locals, (key, value) => { if (key.startsWith('_') || typeof value === 'function') return undefined; return value; }); // Replaces const replaceMap: Record = { 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 { 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 { // 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('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('view.cache')); // Get dependencies css const dependenciesCss: string[] = []; for (const dependency of this.resolveAndCacheDependencies(code, canonicalName)) { if (this.cssCache[dependency] === undefined) { 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('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(); } this.reverseDependencyCache[dependency].add(canonicalViewName); } return dependencies; } }