273 lines
11 KiB
TypeScript
273 lines
11 KiB
TypeScript
import { serveFile } from "@std/http/file-server";
|
|
import { STATUS_TEXT } from "@std/http/status";
|
|
import { type App, toValue } from "vue";
|
|
import { type Router } from "vue-router";
|
|
import { renderToString } from "vue/server-renderer";
|
|
import transpileResponse from "./transpile.ts";
|
|
import { DOMParser } from "@b-fuze/deno-dom";
|
|
import { join } from "@std/path/join";
|
|
import { exists } from "@std/fs";
|
|
import { type DjSSRContext } from "@/useDjSSRContext.ts";
|
|
import { type DjAPIResult, type DjAPIResultMap } from "@/api.ts";
|
|
|
|
const utf8Decoder = new TextDecoder("utf-8");
|
|
|
|
const parser = new DOMParser();
|
|
|
|
function appHeaderScript(params: { ssrContext: DjSSRContext, entryPath: string }) {
|
|
return `
|
|
<title>${ toValue(params.ssrContext.head.title) }</title>
|
|
${ toValue(params.ssrContext.head.metatags).map(_ => `<meta name="${ _.name }" content="${ _.content }">`).join('\n\t') }
|
|
<script type="importmap">
|
|
{
|
|
"imports": {
|
|
"vue": "/deps/vue/dist/vue.esm-browser.prod.js",
|
|
"vue-router": "/deps/vue-router/dist/vue-router.esm-browser.js",
|
|
"vue/jsx-runtime": "/deps/vue/jsx-runtime/index.mjs",
|
|
"@vue/devtools-api": "/app/devtools-shim.ts",
|
|
"@/": "/app/"
|
|
}
|
|
}
|
|
</script>
|
|
<style>
|
|
${ Object.values(params.ssrContext.styles).join('\n') }
|
|
</style>
|
|
<script type="module">
|
|
window.appstate = ${JSON.stringify(params.ssrContext.registry)};
|
|
import('${params.entryPath}');
|
|
</script>`;
|
|
}
|
|
|
|
async function* siteEntries(path: string): AsyncGenerator<string> {
|
|
for await (const dirEnt of Deno.readDir(path)) {
|
|
if (dirEnt.isDirectory) {
|
|
yield* siteEntries(join(path, dirEnt.name));
|
|
} else if (dirEnt.name === "index_template.html") {
|
|
yield path.split("/")[1] ?? "";
|
|
}
|
|
}
|
|
}
|
|
|
|
const publicFiles = siteEntries("public");
|
|
const sites: string[] = [];
|
|
for await (const path of publicFiles) {
|
|
sites.push(path);
|
|
}
|
|
|
|
async function getAPIResponse(apiReq: Request): Promise<Response> {
|
|
let jsonResponse: DjAPIResult | { error: string } | null = null;
|
|
let status = 200;
|
|
|
|
const pathname = URL.parse(apiReq.url)?.pathname;
|
|
|
|
if (!pathname) {
|
|
jsonResponse = { error: "Invalid Route" };
|
|
status = 400;
|
|
}
|
|
|
|
if (!jsonResponse && pathname) {
|
|
const apiPath = pathname.split("/api")[1];
|
|
|
|
if (apiPath === "/rp-articles") {
|
|
const paths: string[] = [];
|
|
const contentDir = './public/generative-energy/content/';
|
|
for await (const dirEnt of Deno.readDir(contentDir)) {
|
|
if (dirEnt.isFile && dirEnt.name.endsWith('.html')) {
|
|
paths.push(`${contentDir}${dirEnt.name}`);
|
|
}
|
|
}
|
|
const result: DjAPIResultMap['/rp-articles'] = [];
|
|
for (const filePath of paths) {
|
|
const content = await Deno.readTextFile(filePath);
|
|
const dom = parser.parseFromString(content, 'text/html');
|
|
const metadata = { title: '', author: 'Ray Peat, übersetzt von Daniel Ledda', titleEn: '', titleDe: '', tags: [] as string[], slug: '' };
|
|
const metaTags = dom.querySelectorAll('meta') as unknown as NodeListOf<HTMLMetaElement>;
|
|
for (const metaTag of metaTags) {
|
|
const name = metaTag.attributes.getNamedItem('name')?.value ?? '';
|
|
const content = metaTag.attributes.getNamedItem('content')?.value ?? '';
|
|
if (name === 'title-de') {
|
|
metadata.titleDe = content;
|
|
metadata.title = content;
|
|
} else if (name === 'title-en') {
|
|
metadata.titleEn = content;
|
|
} else if (name === 'tags') {
|
|
metadata.tags = content ? content.split(",") : [];
|
|
} else if (name === 'slug') {
|
|
metadata.slug = content;
|
|
}
|
|
}
|
|
result.push(metadata);
|
|
}
|
|
result.sort((a, b) => a.titleDe.localeCompare(b.titleDe));
|
|
jsonResponse = result;
|
|
} else if (apiPath === "/blog-entries") {
|
|
const paths: string[] = [];
|
|
const contentDir = './public/blog/content/';
|
|
for await (const dirEnt of Deno.readDir(contentDir)) {
|
|
if (dirEnt.isFile && dirEnt.name.endsWith('.html')) {
|
|
paths.push(`${contentDir}${dirEnt.name}`);
|
|
}
|
|
}
|
|
const result: DjAPIResultMap['/blog-entries'] = [];
|
|
for (const filePath of paths) {
|
|
const [ stat, content ] = await Promise.all([Deno.stat(filePath), Deno.readTextFile(filePath)]);
|
|
const dom = parser.parseFromString(content, 'text/html');
|
|
const metadata = {
|
|
slug: '',
|
|
tags: [] as string[],
|
|
title: '',
|
|
createdAt: '',
|
|
updatedAt: '',
|
|
};
|
|
const metaTags = dom.querySelectorAll('meta') as unknown as NodeListOf<HTMLMetaElement>;
|
|
for (const metaTag of metaTags) {
|
|
const name = metaTag.attributes.getNamedItem('name')?.value ?? '';
|
|
const content = metaTag.attributes.getNamedItem('content')?.value ?? '';
|
|
if (name === 'title') {
|
|
metadata.title = content;
|
|
} else if (name === 'tags') {
|
|
metadata.tags = content ? content.split(",") : [];
|
|
} else if (name === 'slug') {
|
|
metadata.slug = content;
|
|
} else if (name === 'updatedAt') {
|
|
metadata.createdAt = content;
|
|
} else if (name === 'createdAt') {
|
|
metadata.updatedAt = content;
|
|
}
|
|
}
|
|
result.push(metadata);
|
|
}
|
|
result.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
|
jsonResponse = result;
|
|
}
|
|
|
|
if (!jsonResponse) {
|
|
jsonResponse = { error: `API route ${ apiPath } not found.` };
|
|
status = 404;
|
|
}
|
|
}
|
|
|
|
const headers = new Headers();
|
|
headers.set("Content-Type", "application/json");
|
|
return new Response(JSON.stringify(jsonResponse), {
|
|
status,
|
|
headers,
|
|
});
|
|
}
|
|
|
|
const redirects = {
|
|
'/generative-energy/rp-deutsch': {
|
|
target: '/generative-energy/raypeat-deutsch',
|
|
code: 301,
|
|
},
|
|
} as const;
|
|
|
|
const redirectPaths = Object.keys(redirects) as (keyof typeof redirects)[];
|
|
|
|
Deno.serve({
|
|
port: 8080,
|
|
hostname: "0.0.0.0",
|
|
onListen({ port, hostname }) {
|
|
console.log(`Listening on port http://${hostname}:${port}/`);
|
|
},
|
|
}, async (req, _conn) => {
|
|
const timeStart = new Date().getTime();
|
|
|
|
let response: Response | null = null;
|
|
|
|
const url = URL.parse(req.url);
|
|
|
|
if (req.method === "GET") {
|
|
const pathname = url?.pathname ?? "/";
|
|
|
|
// Redirects
|
|
const redirect = redirectPaths.find(_ => pathname.startsWith(_))
|
|
if (response === null && redirect) {
|
|
const entry = redirects[redirect];
|
|
const headers = new Headers();
|
|
headers.set('Location', entry.target);
|
|
response = new Response(STATUS_TEXT[entry.code], { headers, status: entry.code });
|
|
}
|
|
|
|
// API
|
|
if (response === null && pathname.startsWith("/api/")) {
|
|
response = await getAPIResponse(req);
|
|
}
|
|
|
|
// Public/static files
|
|
if (response === null) {
|
|
let filepath = join(".", "public", pathname);
|
|
if (filepath.endsWith("/")) {
|
|
filepath = join(filepath, "index.html");
|
|
}
|
|
if (await exists(filepath, { isFile: true })) {
|
|
response = await serveFile(req, filepath);
|
|
}
|
|
}
|
|
|
|
// NPM Vendor deps
|
|
if (response === null && pathname.startsWith("/deps")) {
|
|
response = await serveFile(req, `node_modules/${pathname.split("/deps")[1]}`);
|
|
}
|
|
|
|
// Transpile Code
|
|
if (
|
|
response === null &&
|
|
pathname.startsWith("/app") &&
|
|
(pathname.endsWith(".ts") || pathname.endsWith(".tsx")) &&
|
|
!pathname.endsWith("server.ts")
|
|
) {
|
|
response = await serveFile(req, "./" + pathname);
|
|
response = await transpileResponse(response, req.url, pathname);
|
|
}
|
|
|
|
// SSR
|
|
if (response === null) {
|
|
const baseDirectoryName = pathname.split("/")[1] ?? "";
|
|
if (sites.includes(baseDirectoryName)) {
|
|
const appLocation = baseDirectoryName === "" ? "home" : baseDirectoryName;
|
|
const siteTemplate = join("public", baseDirectoryName, "index_template.html");
|
|
const siteEntry = join("app", appLocation, "server.ts");
|
|
const clientEntry = join("@", appLocation, "client.ts");
|
|
const { app, router } = (await import("./" + siteEntry)).default() as {
|
|
app: App;
|
|
router: Router | null;
|
|
};
|
|
app.provide("dom-parse", (innerHTML: string) => {
|
|
return parser.parseFromString(innerHTML, "text/html").documentElement;
|
|
});
|
|
const ssrContext: DjSSRContext = { styles: {}, registry: {}, head: { title: "", metatags: [] } };
|
|
if (router) {
|
|
await router.replace(pathname.split('/' + baseDirectoryName)[1]);
|
|
await router.isReady();
|
|
}
|
|
const rendered = await renderToString(app, ssrContext);
|
|
const content = utf8Decoder.decode(await Deno.readFile(siteTemplate))
|
|
.replace(`<!-- SSR OUTLET -->`, rendered)
|
|
.replace(
|
|
`<!-- SSR HEAD OUTLET -->`,
|
|
appHeaderScript({ ssrContext, entryPath: clientEntry }),
|
|
);
|
|
response = new Response(content, { headers: { "Content-Type": "text/html" } });
|
|
}
|
|
}
|
|
} else {
|
|
response = new Response("Only GET allowed.", { status: 500 });
|
|
}
|
|
|
|
if (response === null) {
|
|
response = new Response("Not found.", { status: 404 })
|
|
}
|
|
|
|
const timeEnd = new Date().getTime();
|
|
|
|
console.log(`Request ${ url?.pathname ?? 'malformed' }\tStatus ${ response.status }, Duration ${ timeEnd - timeStart }ms`);
|
|
|
|
return response;
|
|
});
|
|
|
|
Deno.addSignalListener("SIGINT", () => {
|
|
console.info("Shutting down (received SIGINT)");
|
|
Deno.exit();
|
|
});
|