djledda.de main
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

228 lines
8.3 KiB

  1. import { serveFile } from "jsr:@std/http/file-server";
  2. import { STATUS_TEXT } from "jsr:@std/http/status";
  3. import { type App, toValue } from "vue";
  4. import { type Router } from "vue-router";
  5. import { renderToString } from "vue/server-renderer";
  6. import transpileResponse from "./transpile.ts";
  7. import { DOMParser } from "jsr:@b-fuze/deno-dom";
  8. import { join } from "jsr:@std/path/join";
  9. import { exists } from "jsr:@std/fs";
  10. import { type DJSSRContext } from "@/useDJSSRContext.ts";
  11. import { type DJAPIResult, type DJAPIResultMap } from "@/api.ts";
  12. const utf8Decoder = new TextDecoder("utf-8");
  13. const parser = new DOMParser();
  14. function appHeaderScript(params: { ssrContext: DJSSRContext, entryPath: string }) {
  15. return `<script type="importmap">
  16. {
  17. "imports": {
  18. "vue": "/deps/vue/dist/vue.esm-browser.prod.js",
  19. "vue-router": "/deps/vue-router/dist/vue-router.esm-browser.js",
  20. "vue/jsx-runtime": "/deps/vue/jsx-runtime/index.mjs",
  21. "@vue/devtools-api": "/app/devtools-shim.ts",
  22. "@/": "/app/"
  23. }
  24. }
  25. </script>
  26. <style>
  27. ${ Object.values(params.ssrContext.styles).join('\n') }
  28. </style>
  29. <script type="module">
  30. window.appstate = ${JSON.stringify(params.ssrContext.registry)};
  31. import('${params.entryPath}');
  32. </script>`;
  33. }
  34. async function* siteEntries(path: string): AsyncGenerator<string> {
  35. for await (const dirEnt of Deno.readDir(path)) {
  36. if (dirEnt.isDirectory) {
  37. yield* siteEntries(join(path, dirEnt.name));
  38. } else if (dirEnt.name === "index_template.html") {
  39. yield path.split("/")[1] ?? "";
  40. }
  41. }
  42. }
  43. const publicFiles = siteEntries("public");
  44. const sites: string[] = [];
  45. for await (const path of publicFiles) {
  46. sites.push(path);
  47. }
  48. async function getAPIResponse(apiReq: Request): Promise<Response> {
  49. let jsonResponse: DJAPIResult | { error: string } | null = null;
  50. let status = 200;
  51. const pathname = URL.parse(apiReq.url)?.pathname;
  52. if (!pathname) {
  53. jsonResponse = { error: "Invalid Route" };
  54. status = 400;
  55. }
  56. if (!jsonResponse && pathname) {
  57. const apiPath = pathname.split("/api")[1];
  58. if (apiPath === "/rp-articles") {
  59. const paths: string[] = [];
  60. const contentDir = './public/generative-energy/content/';
  61. for await (const dirEnt of Deno.readDir(contentDir)) {
  62. if (dirEnt.isFile && dirEnt.name.endsWith('.html')) {
  63. paths.push(`${contentDir}${dirEnt.name}`);
  64. }
  65. }
  66. const result: DJAPIResultMap['/rp-articles'] = [];
  67. for (const filePath of paths) {
  68. const content = await Deno.readTextFile(filePath);
  69. const dom = parser.parseFromString(content, 'text/html');
  70. const metadata = { title: '', tags: [] as string[], slug: '' };
  71. const metaTags = dom.querySelectorAll('meta') as unknown as NodeListOf<HTMLMetaElement>;
  72. for (const metaTag of metaTags) {
  73. const name = metaTag.attributes.getNamedItem('name')?.value ?? '';
  74. const content = metaTag.attributes.getNamedItem('content')?.value ?? '';
  75. if (name === 'title') {
  76. metadata.title = content;
  77. } else if (name === 'tags') {
  78. metadata.tags = content ? content.split(",") : [];
  79. } else if (name === 'slug') {
  80. metadata.slug = content;
  81. }
  82. }
  83. result.push(metadata);
  84. }
  85. jsonResponse = result;
  86. }
  87. if (!jsonResponse) {
  88. jsonResponse = { error: `API route ${ apiPath } not found.` };
  89. status = 404;
  90. }
  91. }
  92. const headers = new Headers();
  93. headers.set("Content-Type", "application/json");
  94. return new Response(JSON.stringify(jsonResponse), {
  95. status,
  96. headers,
  97. });
  98. }
  99. const redirects = {
  100. '/generative-energy/rp-deutsch': {
  101. target: '/generative-energy/raypeat-deutsch',
  102. code: 301,
  103. },
  104. } as const;
  105. const redirectPaths = Object.keys(redirects) as (keyof typeof redirects)[];
  106. Deno.serve({
  107. port: 8080,
  108. hostname: "0.0.0.0",
  109. onListen({ port, hostname }) {
  110. console.log(`Listening on port http://${hostname}:${port}/`);
  111. },
  112. }, async (req, _conn) => {
  113. const timeStart = new Date().getTime();
  114. let response: Response | null = null;
  115. const url = URL.parse(req.url);
  116. if (req.method === "GET") {
  117. const pathname = url?.pathname ?? "/";
  118. // Redirects
  119. const redirect = redirectPaths.find(_ => pathname.startsWith(_))
  120. if (response === null && redirect) {
  121. const entry = redirects[redirect];
  122. const headers = new Headers();
  123. headers.set('Location', entry.target);
  124. response = new Response(STATUS_TEXT[entry.code], { headers, status: entry.code });
  125. }
  126. // API
  127. if (response === null && pathname.startsWith("/api/")) {
  128. response = await getAPIResponse(req);
  129. }
  130. // Public/static files
  131. if (response === null) {
  132. let filepath = join(".", "public", pathname);
  133. if (filepath.endsWith("/")) {
  134. filepath = join(filepath, "index.html");
  135. }
  136. if (await exists(filepath, { isFile: true })) {
  137. response = await serveFile(req, filepath);
  138. }
  139. }
  140. // NPM Vendor deps
  141. if (response === null && pathname.startsWith("/deps")) {
  142. response = await serveFile(req, `node_modules/${pathname.split("/deps")[1]}`);
  143. }
  144. // Transpile Code
  145. if (
  146. response === null &&
  147. pathname.startsWith("/app") &&
  148. (pathname.endsWith(".ts") || pathname.endsWith(".tsx")) &&
  149. !pathname.endsWith("server.ts")
  150. ) {
  151. response = await serveFile(req, "./" + pathname);
  152. response = await transpileResponse(response, req.url, pathname);
  153. }
  154. // SSR
  155. if (response === null) {
  156. const baseDirectoryName = pathname.split("/")[1] ?? "";
  157. if (sites.includes(baseDirectoryName)) {
  158. const appLocation = baseDirectoryName === "" ? "home" : baseDirectoryName;
  159. const siteTemplate = join("public", baseDirectoryName, "index_template.html");
  160. const siteEntry = join("app", appLocation, "server.ts");
  161. const clientEntry = join("@", appLocation, "client.ts");
  162. const { app, router } = (await import("./" + siteEntry)).default() as {
  163. app: App;
  164. router: Router | null;
  165. };
  166. app.provide("dom-parse", (innerHTML: string) => {
  167. return parser.parseFromString(innerHTML, "text/html").documentElement;
  168. });
  169. const ssrContext: DJSSRContext = { styles: {}, registry: {}, head: { title: "" } };
  170. if (router) {
  171. await router.replace(pathname.split("/generative-energy")[1]);
  172. await router.isReady();
  173. }
  174. const rendered = await renderToString(app, ssrContext);
  175. const content = utf8Decoder.decode(await Deno.readFile(siteTemplate))
  176. .replace(`<!-- SSR OUTLET -->`, rendered)
  177. .replaceAll("%TITLE%", toValue(ssrContext.head?.title) ?? "Site")
  178. .replace(
  179. `<!-- SSR HEAD OUTLET -->`,
  180. appHeaderScript({ ssrContext, entryPath: clientEntry }),
  181. );
  182. response = new Response(content, { headers: { "Content-Type": "text/html" } });
  183. }
  184. }
  185. } else {
  186. response = new Response("Only GET allowed.", { status: 500 });
  187. }
  188. if (response === null) {
  189. response = new Response("Not found.", { status: 404 })
  190. }
  191. const timeEnd = new Date().getTime();
  192. console.log(`Request ${ url?.pathname ?? 'malformed' }\tStatus ${ response.status }, Duration ${ timeEnd - timeStart }ms`);
  193. return response;
  194. });
  195. Deno.addSignalListener("SIGINT", () => {
  196. console.info("Shutting down (received SIGINT)");
  197. Deno.exit();
  198. });