facilmap/server/src/frontend.ts

189 wiersze
5.5 KiB
TypeScript

import type { Manifest } from "vite";
import { paths, serve } from "facilmap-frontend/build.js";
import { readFile } from "node:fs/promises";
import type { ID, PadData, Type } from "facilmap-types";
import * as ejs from "ejs";
import { Router, type RequestHandler } from "express";
import { static as expressStatic } from "express";
import { normalizePadName, type InjectedConfig, quoteHtml, normalizePageTitle, normalizePageDescription, formatTypeName } from "facilmap-utils";
import config from "./config";
import { streamPromiseToStream, streamReplace } from "./utils/streams";
import { ReadableStream } from "stream/web";
import { generateRandomId } from "./utils/utils";
import type { TableParams } from "./export/table";
import { getI18n } from "./i18n";
export const isDevMode = !!process.env.FM_DEV;
async function getViteManifest(): Promise<Manifest> {
const manifest = await readFile(paths.viteManifest);
return JSON.parse(manifest.toString());
}
interface Scripts {
scripts: string[];
preloadScripts: string[];
styles: string[];
}
async function getScripts(entry: "mapEntry" | "tableEntry"): Promise<Scripts> {
if (isDevMode) {
return {
scripts: ["@vite/client", paths[entry]],
preloadScripts: [],
styles: []
};
} else {
const manifest = await getViteManifest();
let referencedChunks = [paths[entry]];
for (let i = 0; i < referencedChunks.length; i++) {
const chunk = manifest[referencedChunks[i]];
for (const reference of [
...chunk.imports ?? [],
...chunk.dynamicImports ?? []
]) {
if (!referencedChunks.includes(reference)) {
referencedChunks.push(reference);
}
}
}
const scripts = referencedChunks.map((c) => manifest[c].file);
const styles = referencedChunks.map((c) => manifest[c].css ?? []);
return {
scripts: scripts.slice(0, 1),
preloadScripts: scripts.slice(1),
styles: styles.flat()
};
}
}
function getInjectedConfig(): InjectedConfig {
return {
appName: config.appName,
openElevationApiUrl: config.openElevationApiUrl,
openElevationThrottleMs: config.openElevationThrottleMs,
openElevationMaxBatchSize: config.openElevationMaxBatchSize,
nominatimUrl: config.nominatimUrl,
limaLabsToken: config.limaLabsToken,
hideCommercialMapLinks: config.hideCommercialMapLinks,
supportsRoutes: !!config.mapboxToken || !!config.orsToken,
supportsAdvancedRoutes: !!config.orsToken
};
}
export interface RenderMapParams {
padData: { name: string | undefined; description: string | undefined; searchEngines: boolean } | undefined;
isReadOnly: boolean;
url: string;
}
export async function renderMap(params: RenderMapParams): Promise<string> {
const [template, injections] = await Promise.all([
readFile(paths.mapEjs).then((t) => t.toString()),
getScripts("mapEntry")
]);
return ejs.render(template, {
appName: config.appName,
config: getInjectedConfig(),
hasCustomCssFile: !!config.customCssFile,
normalizePageTitle,
normalizePageDescription,
normalizePadName,
i18n: getI18n(),
...injections,
paths,
...params
});
}
export function renderTable({ padData, types, renderSingleTable, url }: {
padData: PadData;
types: Type[];
renderSingleTable: (typeId: ID, params: TableParams) => ReadableStream<string>;
url: string;
}): ReadableStream<string> {
return streamPromiseToStream((async () => {
const [template, injections] = await Promise.all([
readFile(paths.tableEjs).then((t) => t.toString()),
getScripts("tableEntry")
]);
const replace: Record<string, ReadableStream<string>> = {};
const rendered = ejs.render(template, {
...injections,
appName: config.appName,
hasCustomCssFile: !!config.customCssFile,
paths,
normalizePadName,
normalizePageTitle,
normalizePageDescription,
formatTypeName,
quoteHtml,
renderSingleTable: (typeId: ID, params: TableParams) => {
const placeholder = `%${generateRandomId(32)}%`;
replace[placeholder] = renderSingleTable(typeId, params);
return placeholder;
},
padData,
types,
url
});
return streamReplace(rendered, replace);
})());
}
export async function getStaticFrontendMiddleware(): Promise<RequestHandler> {
if (isDevMode) {
const devServer = await serve({
server: {
middlewareMode: true
},
appType: "custom"
});
return devServer.middlewares;
} else {
const router = Router();
router.use(`${paths.base}assets/`, (req, res, next) => {
res.setHeader('Cache-Control', 'public, max-age=315576000, immutable'); // 10 years
next();
});
router.use(paths.base, expressStatic(paths.dist));
return router;
}
}
export async function getPwaManifest(): Promise<string> {
const template = await readFile(paths.pwaManifest).then((t) => t.toString());
return template.replaceAll("%APP_NAME%", config.appName);
}
export async function getOpensearchXml(baseUrl: string): Promise<string> {
const template = await readFile(paths.opensearchXmlEjs).then((t) => t.toString());
return ejs.render(template, {
appName: config.appName,
baseUrl
});
}
export function getOembedJson(baseUrl: string, padData: PadData | undefined, params: { url: string; maxwidth?: number; maxheight?: number }): string {
const width = params.maxwidth ?? 800;
const height = params.maxheight ?? 500;
return JSON.stringify({
"title": normalizePageTitle(padData ? normalizePadName(padData.name) : undefined, config.appName),
"type": "rich",
"height": height,
"width": width,
"version": "1.0",
"provider_name": config.appName,
"provider_url": baseUrl,
"html": `<iframe style="height:${height}px; width:${width}px; border:none;" src="${quoteHtml(params.url)}"></iframe>`
});
}