Files
djledda-web/main.ts
Daniel Ledda ba9bcbaa08 update
2026-01-18 22:22:01 +01:00

326 lines
12 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 HOST = 'https://djledda.net';
const parser = new DOMParser();
async function getBlogEntries() {
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 content = await Deno.readTextFile(filePath);
const dom = parser.parseFromString(content, 'text/html');
const metadata = {
slug: '',
tags: [] as string[],
guid: '',
title: '',
createdAt: '',
updatedAt: '',
teaser: `${ dom.querySelector('article').textContent.slice(0, 128) }...`,
};
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 === 'guid') {
metadata.guid = content;
} 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(b.createdAt).getTime() - new Date(a.createdAt).getTime());
return result;
}
async function rss() {
const articles = await getBlogEntries();
return `<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>djledda's blog</title>
<description>djledda's personal blog</description>
<link>${ HOST }/blog</link>
<managingEditor>Daniel Ledda</managingEditor>
<image>
<title>djledda's blog</title>
<url>${ HOST }/favicon.png</url>
<link>${ HOST }/blog</link>
<description>djledda's personal blog</description>
</image>
<language>en-au</language>
<copyright>${ new Date().getFullYear() } djledda.net All rights reserved</copyright>
<lastBuildDate>${ new Date(articles.at(-1)!.updatedAt).toUTCString() }</lastBuildDate>
<pubDate>${ new Date(articles.at(-1)!.updatedAt).toUTCString() }</pubDate>
<ttl>1440</ttl>
${ articles.map(article => `<item>
<title>${ article.title }</title>
<link>${ HOST }/blog/${ article.slug }</link>
<pubDate>${ new Date(article.createdAt).toUTCString() }</pubDate>
<author>Daniel Ledda</author>
<description>${ article.content }</description>
<guid>${ article.guid }</guid>
</item>
`).join('')}
<atom:link href="${ HOST }/blog/djblog.rss" rel="self" type="application/rss+xml" />
</channel>
</rss>`;
}
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.html") {
yield path.split("/")[1] ?? "";
}
}
}
const sites: string[] = [];
for await (const entry of siteEntries("app")) {
sites.push(entry);
}
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") {
jsonResponse = await getBlogEntries();
}
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);
}
// RSS
if (response === null) {
if (pathname === '/blog/djblog.rss') {
response = new Response(await rss(), { status: 200 });
}
}
// 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) {
let baseDirectoryName = pathname.split("/")[1] ?? "";
baseDirectoryName = baseDirectoryName === "" ? "home" : baseDirectoryName;
if (sites.includes(baseDirectoryName)) {
const siteTemplate = join("app", baseDirectoryName, "index.html");
const siteEntry = join("app", baseDirectoryName, "server.ts");
const clientEntry = join("@", baseDirectoryName, "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();
});