add files
This commit is contained in:
commit
f79b0dc5a0
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
/*
|
||||||
|
!/example
|
||||||
|
!/src
|
||||||
|
!/deno.json
|
||||||
|
!/Dockerfile
|
||||||
|
!/.gitignore
|
||||||
|
!/README.md
|
34
Dockerfile
Normal file
34
Dockerfile
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
FROM denoland/deno:alpine as prod
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
VOLUME /plugins
|
||||||
|
VOLUME /views
|
||||||
|
VOLUME /static
|
||||||
|
VOLUME /config
|
||||||
|
|
||||||
|
STOPSIGNAL SIGINT
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --start-period=1s CMD curl localhost:8080/.well-known/keepalive | grep "awake and alive"
|
||||||
|
ENTRYPOINT deno run\
|
||||||
|
--allow-read=config.dev.yml\
|
||||||
|
--allow-read=config.test.yml\
|
||||||
|
--allow-net=0.0.0.0:8080\
|
||||||
|
--allow-sys=homedir\
|
||||||
|
--allow-read=./\
|
||||||
|
--allow-read=/plugins\
|
||||||
|
--allow-read=/views\
|
||||||
|
--allow-read=/static\
|
||||||
|
--allow-read=/config\
|
||||||
|
/app/src/main.ts\
|
||||||
|
/app/config.yml\
|
||||||
|
$(find /config -name "*.yml")
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY deno.json ./
|
||||||
|
RUN deno install
|
||||||
|
|
||||||
|
COPY src ./src/
|
67
README.md
Normal file
67
README.md
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
# My website platform
|
||||||
|
|
||||||
|
I use this custom layer on top of PUG for my personal web page. It allows you to write full HTML pages, as well as simple markdown pages.
|
||||||
|
|
||||||
|
## How to use?
|
||||||
|
|
||||||
|
Using Deno, just running `src/main.ts` with an argument to the config file will start the server. The config consists of the following fields:
|
||||||
|
|
||||||
|
- ignore: consists of a list of paths that should be invisible to the public
|
||||||
|
- plugins: a list of plugins that should be loaded
|
||||||
|
- libs: a path to the folder which contains the plugins
|
||||||
|
- views: a path to the folder which contains the views
|
||||||
|
- maxCache: how many seconds should pass, before a cached page is discarded
|
||||||
|
- debug: If true, will output more information
|
||||||
|
- static: If provided, will be a path which contains statically-served resources
|
||||||
|
|
||||||
|
- notFound: The name of the not found page
|
||||||
|
- defaultPage: The name of the page template (the template for a file)
|
||||||
|
- defaultIndex: The name of the index template (the template for a folder)
|
||||||
|
- defaultTitle: If the page has no title, this will be used instead
|
||||||
|
|
||||||
|
- Everything else: Will become a default value for a global variable
|
||||||
|
|
||||||
|
### Structure
|
||||||
|
|
||||||
|
There is one folder - the views folder, which contains the files that will be rendered and served by the server. Resolving a view is not too different from resolving a NodeJS module - the server will search for these paths, in this order:
|
||||||
|
|
||||||
|
- ${viewsDir}/${path}/index.pug
|
||||||
|
- ${viewsDir}/${path}/index.md
|
||||||
|
- ${viewsDir}/${path}.pug
|
||||||
|
- ${viewsDir}/${path}.md
|
||||||
|
- ${viewsDir}/${notFound}.pug
|
||||||
|
- ${viewsDir}/${notFound}.md
|
||||||
|
- (built-in fallback error message)
|
||||||
|
|
||||||
|
The first path that is found will be used as the rendered file. If the found file is an index file, it will be rendered with the index base, otherwise, the page base will be used instead.
|
||||||
|
|
||||||
|
First things first, the server will resolve the metadata that the view provides (if any). That metadata will be overlaid over the config and will be used to resolve the base of the page. After that, our view will be rendered, and the output will be put in the metadata option "text". Of course, having a base page that is not `pug` would be useless, as markdown has no templating capabilities.
|
||||||
|
|
||||||
|
In a pug view, you will be able to access all metadata and config as global variables. The metadata will be overlaid over the global config.
|
||||||
|
|
||||||
|
In a JS view, you will be able to access the following relevant modules, which will allow you to get information about the current view:
|
||||||
|
- !lib
|
||||||
|
- View - the base class for all views
|
||||||
|
- config - the global config
|
||||||
|
- isIgnored - a function that checks whether the given path is ignored
|
||||||
|
- list - a utility function that converts an unknown value to a set of strings
|
||||||
|
- getPlugin - gets the function that calls a plugin (we will talk about them later)
|
||||||
|
- getLocals - gets an object, representing the current running metadata
|
||||||
|
- resolveView - from a given URL, returns the corresponding View object
|
||||||
|
- resolveViewFromUri - from a given filesystem path, returns the corresponding View object
|
||||||
|
- type Plugin
|
||||||
|
- type Locals
|
||||||
|
- default.View - same as View
|
||||||
|
- default.config - same as config
|
||||||
|
- default.isIgnored - same as isIgnored
|
||||||
|
- default.locals - a getter that calls getLocals
|
||||||
|
|
||||||
|
### Plugins
|
||||||
|
|
||||||
|
Plugins are used to provide additional reusable functionality for the views. They can be put in the configured plugins folder. Due to how the import system works, all plugins that will be used need to be listed in the config (they will be preloaded). A plugin must have its default export a function. That function will take a view as its first argument, and additional user-defined arguments. The function may return anything
|
||||||
|
|
||||||
|
Invoking plugins in Pug code can be done with the additionally exposed function `invoke(name, ...args)`. The current view will be passed as the first argument of the plugin.
|
||||||
|
|
||||||
|
## Deploying
|
||||||
|
|
||||||
|
It is as simple as building, using the included Dockerfile.
|
23
deno.json
Normal file
23
deno.json
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"tasks": {
|
||||||
|
"dev": "deno run --watch main.ts"
|
||||||
|
},
|
||||||
|
"imports": {
|
||||||
|
"@std/http": "jsr:@std/http@^1.0.9",
|
||||||
|
"@std/path": "jsr:@std/path@^1.0.7",
|
||||||
|
"@std/yaml": "jsr:@std/yaml@^1.0.5",
|
||||||
|
"highlight.js": "npm:highlight.js@^11.10.0",
|
||||||
|
"markdown-it": "npm:markdown-it@^14.1.0",
|
||||||
|
"markdown-it-front-matter": "npm:markdown-it-front-matter@^0.2.4",
|
||||||
|
"pug": "npm:pug@^3.0.3",
|
||||||
|
"!lib": "./src/lib.ts"
|
||||||
|
},
|
||||||
|
"lint": {
|
||||||
|
"rules": {
|
||||||
|
"exclude": [ "no-explicit-any", "no-cond-assign", "require-await", "no-regex-spaces", "no-empty" ]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fmt": {
|
||||||
|
"indentWidth": 4
|
||||||
|
}
|
||||||
|
}
|
13
example/config.dev.yml
Normal file
13
example/config.dev.yml
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
plugins: []
|
||||||
|
libs: data/plugins
|
||||||
|
views: data/views
|
||||||
|
static: data/static
|
||||||
|
|
||||||
|
notFound: .templ/not-found
|
||||||
|
defaultPage: .templ/page
|
||||||
|
defaultIndex: .templ/index
|
||||||
|
|
||||||
|
debug: true
|
||||||
|
maxCache: 30
|
||||||
|
ignore:
|
||||||
|
- /.**
|
13
example/config.prod.yml
Normal file
13
example/config.prod.yml
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
libs: /plugins
|
||||||
|
views: /views
|
||||||
|
static: /static
|
||||||
|
|
||||||
|
notFound: .templ/not-found
|
||||||
|
defaultPage: .templ/page
|
||||||
|
defaultIndex: .templ/index
|
||||||
|
plugins: []
|
||||||
|
|
||||||
|
debug: false
|
||||||
|
maxCache: 86400
|
||||||
|
ignore:
|
||||||
|
- /.**
|
5
example/config.test.yml
Normal file
5
example/config.test.yml
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
plugins:
|
||||||
|
- breadcrumbs
|
||||||
|
- dir
|
||||||
|
- navbar
|
||||||
|
debug: false
|
102
src/config.ts
Normal file
102
src/config.ts
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
import { parse as parseYaml } from "@std/yaml";
|
||||||
|
import { list } from "./main.ts";
|
||||||
|
import { normalize } from "@std/path/posix";
|
||||||
|
|
||||||
|
interface Config {
|
||||||
|
ignore: Set<string>;
|
||||||
|
plugins: Set<string>;
|
||||||
|
|
||||||
|
maxCache?: number;
|
||||||
|
debug: boolean;
|
||||||
|
track?: boolean;
|
||||||
|
static?: string;
|
||||||
|
libs?: string;
|
||||||
|
views?: string;
|
||||||
|
notFound: string;
|
||||||
|
|
||||||
|
defaultPage?: string;
|
||||||
|
defaultIndex?: string;
|
||||||
|
defaultTitle?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function merge<T>(...objs: T[]): T {
|
||||||
|
if (objs.length === 0) throw new Error("Expected at least one object to merge");
|
||||||
|
let res = objs[0];
|
||||||
|
|
||||||
|
for (const obj of objs.slice(1)) {
|
||||||
|
if (obj == null) res = undefined as T;
|
||||||
|
else if (Array.isArray(obj)) {
|
||||||
|
if (Array.isArray(res)) res.push(...obj);
|
||||||
|
else res = obj;
|
||||||
|
}
|
||||||
|
else if (typeof obj === "object") {
|
||||||
|
if (typeof res !== "object" || res == null) {
|
||||||
|
res = obj;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
for (const key in obj) {
|
||||||
|
if (key in res) res[key] = merge(res[key], obj[key]);
|
||||||
|
else res[key] = obj[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else res = obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readConfig() {
|
||||||
|
const objs = await Promise.all(Deno.args.map(async v => parseYaml(await Deno.readTextFile(v)) as Config));
|
||||||
|
const res = merge(...objs);
|
||||||
|
res.ignore = new Set(list(res.ignore).map(v => normalize(v)));
|
||||||
|
res.plugins = new Set(list(res.plugins).map(v => normalize(v)));
|
||||||
|
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await readConfig();
|
||||||
|
|
||||||
|
export default config;
|
||||||
|
|
||||||
|
export let isIgnored = (path: string): boolean => {
|
||||||
|
const regexs: string[] = [];
|
||||||
|
|
||||||
|
for (let predicate of config.ignore) {
|
||||||
|
predicate = normalize(predicate);
|
||||||
|
let absolute;
|
||||||
|
|
||||||
|
if (predicate === "/") {
|
||||||
|
isIgnored = () => true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (predicate.startsWith("/")) {
|
||||||
|
absolute = true;
|
||||||
|
predicate = predicate.slice(1);
|
||||||
|
}
|
||||||
|
if (predicate.endsWith("/")) {
|
||||||
|
predicate = predicate.slice(0, -1);
|
||||||
|
}
|
||||||
|
if (predicate.startsWith("./") ||
|
||||||
|
predicate.startsWith("../") ||
|
||||||
|
predicate == ".." ||
|
||||||
|
predicate == ".") throw new Error(`'${predicate}' in ignore list includes relative specifiers`);
|
||||||
|
|
||||||
|
const body = predicate.split("/")
|
||||||
|
.filter(v => v !== "")
|
||||||
|
.map(v => v.replace(/\*\*|[.*+?^${}()|[\]\\]/g, val => {
|
||||||
|
if (val === "*") return "[^/]*";
|
||||||
|
else if (val === "**") return ".*";
|
||||||
|
else return "\\" + val;
|
||||||
|
}))
|
||||||
|
.join("/");
|
||||||
|
|
||||||
|
regexs.push(absolute ? "(^/" + body + "/)" : "(/" + body + "/)");
|
||||||
|
}
|
||||||
|
|
||||||
|
const regex = new RegExp(regexs.join("|"));
|
||||||
|
|
||||||
|
isIgnored = path => regex.test(normalize("/" + path + "/"));
|
||||||
|
return isIgnored(path);
|
||||||
|
};
|
14
src/lib.ts
Normal file
14
src/lib.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import View, { getLocals, type Locals } from "./views/View.ts";
|
||||||
|
import config, { isIgnored } from "./config.ts";
|
||||||
|
import { list } from "./main.ts";
|
||||||
|
import { getPlugin, type Plugin } from "./plugins.ts";
|
||||||
|
import { resolveViewFromUri, resolveView } from "./views/resolution.ts";
|
||||||
|
|
||||||
|
export { View, config, isIgnored, list, getPlugin, resolveView, resolveViewFromUri, getLocals, type Plugin, type Locals };
|
||||||
|
|
||||||
|
export default {
|
||||||
|
View,
|
||||||
|
config,
|
||||||
|
isIgnored,
|
||||||
|
get locals() { return getLocals(); }
|
||||||
|
}
|
114
src/main.ts
Normal file
114
src/main.ts
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
import "./plugins.ts";
|
||||||
|
|
||||||
|
import config from "./config.ts";
|
||||||
|
import { serveDir } from "@std/http";
|
||||||
|
import { normalize, resolve } from "@std/path/posix";
|
||||||
|
import { renderView } from "./views/resolution.ts";
|
||||||
|
import { loadPlugins } from "./plugins.ts";
|
||||||
|
|
||||||
|
export function list(data: string | Iterable<string> | null | undefined): string[] {
|
||||||
|
if (Array.isArray(data)) return data.flatMap(list);
|
||||||
|
else if (typeof data === "string") return String(data ?? "")
|
||||||
|
.split(/[,;]/)
|
||||||
|
.map(v => v.trim())
|
||||||
|
.filter(v => v !== "");
|
||||||
|
else return [...(data ?? [])].flatMap(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function main() {
|
||||||
|
await loadPlugins();
|
||||||
|
|
||||||
|
Deno.addSignalListener("SIGINT", async () => {
|
||||||
|
console.log("Stopping server");
|
||||||
|
server.shutdown();
|
||||||
|
Deno.exit();
|
||||||
|
});
|
||||||
|
|
||||||
|
const server = Deno.serve({ port: 8080 }, async req => {
|
||||||
|
try {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
|
||||||
|
if (url.pathname === "/.well-known/keepalive") {
|
||||||
|
return new Response("awake and alive");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.static != null) {
|
||||||
|
const res = await serveDir(req, {
|
||||||
|
showIndex: false,
|
||||||
|
fsRoot: resolve(config.static),
|
||||||
|
showDotfiles: false,
|
||||||
|
quiet: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== 404) return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = normalize("/" + url.pathname);
|
||||||
|
const view = (renderView(path, { params: url.searchParams }, true))?.text;
|
||||||
|
if (view != null) return new Response(view, { headers: { "content-type": "text/html" } });
|
||||||
|
|
||||||
|
if (config.notFound != null && req.method === "GET") {
|
||||||
|
const view = (renderView(config.notFound, { params: url.searchParams }, true))?.text;
|
||||||
|
if (view != null) return new Response(view, { headers: { "content-type": "text/html" }, status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response("Not found", { status: 404 });
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
console.error(String(e));
|
||||||
|
if (config.debug) {
|
||||||
|
return new Response("Internal error: " + String(e), { status: 500 });
|
||||||
|
}
|
||||||
|
else return new Response("Internal error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.main) {
|
||||||
|
main();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// const app = express();
|
||||||
|
// export const lib = { View, PugView, MarkdownView, JSView, list, merge };
|
||||||
|
|
||||||
|
// app.use('/.well-known/keepalive', (req, res) => void res.send("awake and alive"));
|
||||||
|
|
||||||
|
// if (config.static != null) app.use(express.static(config.static));
|
||||||
|
|
||||||
|
// if (config.views != null) app.get("/*", (req, res, next) => {
|
||||||
|
// const path = normalize("/" + req.path);
|
||||||
|
|
||||||
|
// // for (const prefix of config.ignore) {
|
||||||
|
// // if (path.startsWith(prefix)) {
|
||||||
|
// // return next();
|
||||||
|
// // }
|
||||||
|
// // }
|
||||||
|
|
||||||
|
// const view = View.render(path, { query: req.query, params: req.params }, true);
|
||||||
|
|
||||||
|
// if (view == null) next();
|
||||||
|
// // else if (view.cached) res.sendStatus(304);
|
||||||
|
// else res.send(view.text);
|
||||||
|
// });
|
||||||
|
|
||||||
|
// if (config.views != null) app.use((req, res) => {
|
||||||
|
// res.status(404);
|
||||||
|
// if (req.method === "GET") {
|
||||||
|
// console.log(config.notFound);
|
||||||
|
// const view = View.render(config.notFound, { query: req.query, params: req.params }, true, true);
|
||||||
|
|
||||||
|
// if (view == null) res.send("Not found :/");
|
||||||
|
// // else if (view.cached) res.sendStatus(304);
|
||||||
|
// else res.send(view.text);
|
||||||
|
// }
|
||||||
|
// else res.send("Not found :/");
|
||||||
|
// });
|
||||||
|
// else app.use((req, res) => {
|
||||||
|
// res.sendStatus(404);
|
||||||
|
// });
|
||||||
|
|
||||||
|
// app.listen(8080, () => console.log("Listening!"));
|
||||||
|
|
||||||
|
|
||||||
|
// // Learn more at https://docs.deno.com/runtime/manual/examples/module_metadata#concepts
|
24
src/plugins.ts
Normal file
24
src/plugins.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { resolve } from "@std/path/posix";
|
||||||
|
import type View from "./views/View.ts";
|
||||||
|
import config from "./config.ts";
|
||||||
|
|
||||||
|
export type Plugin = (this: View, ...args: unknown[]) => unknown;
|
||||||
|
|
||||||
|
const plugins = new Map<string, Plugin>();
|
||||||
|
let loaded = false;
|
||||||
|
|
||||||
|
export function getPlugin(name: string) {
|
||||||
|
const res = plugins.get(name);
|
||||||
|
if (res == null) throw new Error(`Plugin ${name} not loaded (maybe you forgot to include it in the config?)`);
|
||||||
|
else return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadPlugins() {
|
||||||
|
if (loaded) return;
|
||||||
|
loaded = true;
|
||||||
|
|
||||||
|
for (const plugin of config.plugins) {
|
||||||
|
const func = (await import(resolve(config.libs + "/" + plugin + ".ts"))).default;
|
||||||
|
plugins.set(plugin, func);
|
||||||
|
}
|
||||||
|
}
|
49
src/views/JSView.ts
Normal file
49
src/views/JSView.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import { invoke, resolveView } from "./resolution.ts";
|
||||||
|
import View, { popLocals, pushLocals, type Locals } from "./View.ts";
|
||||||
|
|
||||||
|
export default class JSView extends View {
|
||||||
|
public override onMetadata() {
|
||||||
|
const res = [];
|
||||||
|
|
||||||
|
for (const match of this.src.matchAll(/^(?:#!.+?\n)?\s*\/\*---\n(.+)\n---\*\/$/g)) {
|
||||||
|
res.push(match[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
public override onRender(locals: Locals = {}, full = false): string {
|
||||||
|
locals = this.locals(locals);
|
||||||
|
let res: any, text;
|
||||||
|
|
||||||
|
try {
|
||||||
|
pushLocals(locals);
|
||||||
|
res = invoke(this, this.path);
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
popLocals();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof res === "object" && res != null && typeof res[Symbol.iterator] === "function") {
|
||||||
|
res = res[Symbol.iterator]();
|
||||||
|
const parts = [];
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const el = res.next();
|
||||||
|
if (el.value != null) parts.push(el.value);
|
||||||
|
if (el.done) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
text = parts.join("");
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
text = String(res ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (full && locals.page) {
|
||||||
|
const parent = resolveView(String(locals.page), false, true);
|
||||||
|
return parent?.render({ ...locals, text }, full) ?? text;
|
||||||
|
}
|
||||||
|
else return text;
|
||||||
|
}
|
||||||
|
}
|
54
src/views/MarkdownView.ts
Normal file
54
src/views/MarkdownView.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import frontMatter from "markdown-it-front-matter";
|
||||||
|
import markdown from "markdown-it";
|
||||||
|
import hljs from "highlight.js";
|
||||||
|
import View, { type Locals, type Metadata } from "./View.ts";
|
||||||
|
import { resolveView } from "./resolution.ts";
|
||||||
|
import { parse as parseYaml } from "@std/yaml";
|
||||||
|
|
||||||
|
export default class MarkdownView extends View {
|
||||||
|
public static metadata: Metadata;
|
||||||
|
public static readonly renderer = markdown({
|
||||||
|
html: true,
|
||||||
|
xhtmlOut: false,
|
||||||
|
breaks: false,
|
||||||
|
langPrefix: 'language-',
|
||||||
|
linkify: true,
|
||||||
|
typographer: false,
|
||||||
|
quotes: '“”‘’',
|
||||||
|
highlight: (str: string, lang: string) => {
|
||||||
|
if (lang && hljs.getLanguage(lang)) {
|
||||||
|
try {
|
||||||
|
return hljs.highlight(str, { language: lang }).value;
|
||||||
|
}
|
||||||
|
catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}).use(frontMatter, (v: Metadata) => this.metadata = v);
|
||||||
|
|
||||||
|
public override onMetadata() {
|
||||||
|
MarkdownView.renderMarkdown(this.src, { metadata: true }, false);
|
||||||
|
return MarkdownView.metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override onRender(locals: Locals, full = false) {
|
||||||
|
locals = this.locals(locals);
|
||||||
|
|
||||||
|
const text = MarkdownView.renderMarkdown(this.src, locals, full);
|
||||||
|
|
||||||
|
if (full && locals.page) {
|
||||||
|
const parent = resolveView(String(locals.page), false, true);
|
||||||
|
return parent?.render({ ...locals, text }, full) ?? text;
|
||||||
|
}
|
||||||
|
else return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static renderMarkdown(text: string, _locals: object, _full: boolean): string {
|
||||||
|
this.metadata = "{}" as any;
|
||||||
|
const res = this.renderer.render(text);
|
||||||
|
this.metadata = parseYaml(this.metadata as any) as Metadata;
|
||||||
|
return res;
|
||||||
|
// return (await MarkdownView.renderer.render(text, { metadata: true })).value;
|
||||||
|
}
|
||||||
|
}
|
46
src/views/PugView.ts
Normal file
46
src/views/PugView.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import View, { type Locals } from "./View.ts";
|
||||||
|
import config from "../config.ts";
|
||||||
|
import { compile } from "pug";
|
||||||
|
import MarkdownView from "./MarkdownView.ts";
|
||||||
|
import { invoke, renderView } from "./resolution.ts";
|
||||||
|
|
||||||
|
export default class PugView extends View {
|
||||||
|
public override onMetadata() {
|
||||||
|
const src = this.src;
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
for (const match of src.matchAll(/\/\/---\n((?: .+?\n)*)\/\/---/g)) {
|
||||||
|
lines.push(...match[1].split("\n").map(v => v.slice(4)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join("\n") ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
public override onRender(locals: Locals, full: boolean): string {
|
||||||
|
locals = this.locals(locals);
|
||||||
|
const src = this.src;
|
||||||
|
|
||||||
|
const func = compile(src, {
|
||||||
|
basedir: config.views,
|
||||||
|
compileDebug: false,
|
||||||
|
debug: config.debug,
|
||||||
|
pretty: config.debug,
|
||||||
|
doctype: 'HTML',
|
||||||
|
filters: {
|
||||||
|
md: (src: string) => MarkdownView.renderMarkdown(src, {}, false),
|
||||||
|
},
|
||||||
|
globals: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = func({
|
||||||
|
...locals,
|
||||||
|
invoke: invoke.bind(View, this),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (full && !src.match(/^extends\s/g) && !src.match(/^doctype\s/g)) {
|
||||||
|
if (locals.page != null) return renderView(String(locals.page), { ...locals, text: res }, false, true, true)?.text ?? res;
|
||||||
|
}
|
||||||
|
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
}
|
118
src/views/View.ts
Normal file
118
src/views/View.ts
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
import { parse as parseYaml } from "@std/yaml";
|
||||||
|
import config from "../config.ts";
|
||||||
|
|
||||||
|
export type Locals = Record<string, unknown>;
|
||||||
|
export type Metadata = Record<string, unknown>;
|
||||||
|
export type ViewConstr = new (mtime: number, path: string, uri: string, dirMtime: number | undefined, dirPath: string, dirUri: string, cached: boolean) => View;
|
||||||
|
|
||||||
|
const stack: Locals[] = [];
|
||||||
|
|
||||||
|
export function pushLocals(locals: Locals) {
|
||||||
|
stack.push(locals);
|
||||||
|
}
|
||||||
|
export function popLocals() {
|
||||||
|
stack.pop();
|
||||||
|
}
|
||||||
|
export function getLocals() {
|
||||||
|
const res = stack.at(-1);
|
||||||
|
if (res == null) throw new Error("getLocals() not called in a render context");
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default abstract class View {
|
||||||
|
public static cache = new Map<string, View>();
|
||||||
|
|
||||||
|
private _cache?: string;
|
||||||
|
private _metadata?: Metadata;
|
||||||
|
private _src?: string;
|
||||||
|
private _timer?: number;
|
||||||
|
|
||||||
|
public get key() {
|
||||||
|
return this.path;
|
||||||
|
}
|
||||||
|
public get isIndex() {
|
||||||
|
return this.dirMtime != null;
|
||||||
|
}
|
||||||
|
public get hasCache() {
|
||||||
|
return this._cache != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get metadata() {
|
||||||
|
if (this._metadata != null) return this._metadata;
|
||||||
|
|
||||||
|
let res = this.onMetadata();
|
||||||
|
if (typeof res === "string") res = parseYaml(res) as Metadata ?? {};
|
||||||
|
if (typeof res !== "object") res = { data: res };
|
||||||
|
if (this.cached) this._metadata = res as Metadata;
|
||||||
|
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get src() {
|
||||||
|
return this._src ??= Deno.readTextFileSync(this.uri);
|
||||||
|
}
|
||||||
|
public get title() {
|
||||||
|
return String(this.metadata.title ?? this.path.split("/").at(-1));
|
||||||
|
}
|
||||||
|
public get date() {
|
||||||
|
const rawDate = this.metadata.date as any;
|
||||||
|
if (rawDate == null) return undefined;
|
||||||
|
|
||||||
|
const res = new Date(rawDate);
|
||||||
|
if (res == null) return undefined;
|
||||||
|
else return res.toISOString().split("T")[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
public setTimeout() {
|
||||||
|
if (this._timer != null) clearTimeout(this._timer);
|
||||||
|
if (config.maxCache != null) this._timer = setTimeout(() => {
|
||||||
|
View.cache.delete(this.key);
|
||||||
|
if (config.debug) console.log("FREE " + this.path);
|
||||||
|
}, config.maxCache * 1000);
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
public clearTimeout() {
|
||||||
|
if (this._timer != null) clearTimeout(this._timer);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected abstract onMetadata(): Metadata | string;
|
||||||
|
protected abstract onRender(locals: Locals, full: boolean): string;
|
||||||
|
|
||||||
|
protected locals(locals: Locals) {
|
||||||
|
return {
|
||||||
|
title: config.defaultTitle,
|
||||||
|
page: this.isIndex ? config.defaultIndex : config.defaultPage,
|
||||||
|
...locals,
|
||||||
|
...this.metadata
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public render(locals: Locals, full: boolean) {
|
||||||
|
if (this._cache != null) return this._cache;
|
||||||
|
|
||||||
|
pushLocals(locals);
|
||||||
|
try {
|
||||||
|
const res = this.onRender(locals, full);
|
||||||
|
if (this.cached) this._cache = res;
|
||||||
|
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
popLocals();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
public readonly mtime: number,
|
||||||
|
public readonly uri: string,
|
||||||
|
public readonly path: string,
|
||||||
|
public readonly dirMtime: number | undefined,
|
||||||
|
public readonly dirUri: string,
|
||||||
|
public readonly dirPath: string,
|
||||||
|
public readonly cached = true
|
||||||
|
) { }
|
||||||
|
|
||||||
|
}
|
218
src/views/resolution.ts
Normal file
218
src/views/resolution.ts
Normal file
@ -0,0 +1,218 @@
|
|||||||
|
import config, { isIgnored } from "../config.ts";
|
||||||
|
import type { ViewConstr } from "./View.ts";
|
||||||
|
import { dirname, normalize, resolve } from "@std/path/posix";
|
||||||
|
import MarkdownView from "./MarkdownView.ts";
|
||||||
|
import PugView from "./PugView.ts";
|
||||||
|
import JSView from "./JSView.ts";
|
||||||
|
import { getPlugin } from "../plugins.ts";
|
||||||
|
import View from "./View.ts";
|
||||||
|
|
||||||
|
let getType = (ext: string) => {
|
||||||
|
const types = {
|
||||||
|
".md": MarkdownView,
|
||||||
|
".pug": PugView,
|
||||||
|
".js": JSView,
|
||||||
|
".ts": JSView,
|
||||||
|
};
|
||||||
|
|
||||||
|
getType = ext => types[ext as keyof typeof types];
|
||||||
|
return types[ext as keyof typeof types];
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryStatFile(path: string) {
|
||||||
|
try {
|
||||||
|
return Deno.statSync(path);
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryMtime(path: string | Deno.FileInfo | undefined) {
|
||||||
|
if (typeof path === "string") {
|
||||||
|
const stat = tryStatFile(path);
|
||||||
|
if (stat?.mtime != null) return Number(stat.mtime);
|
||||||
|
else return undefined;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
if (path?.mtime != null) return Number(path.mtime);
|
||||||
|
else return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function of(
|
||||||
|
constr: ViewConstr,
|
||||||
|
uri: string, path: string, stat: Deno.FileInfo | undefined,
|
||||||
|
dirUri: string, dirPath: string, dirStat: Deno.FileInfo | undefined | boolean,
|
||||||
|
cached = true, force = false
|
||||||
|
) {
|
||||||
|
stat ??= tryStatFile(uri);
|
||||||
|
const mtime = tryMtime(stat);
|
||||||
|
|
||||||
|
if (stat?.isFile) {
|
||||||
|
if (!force && isIgnored(path)) return undefined;
|
||||||
|
if (dirStat === true) {
|
||||||
|
dirStat = tryStatFile(dirUri) ?? undefined;
|
||||||
|
if (dirStat == null) return undefined;
|
||||||
|
}
|
||||||
|
else if (dirStat === false) dirStat = undefined;
|
||||||
|
if (typeof dirStat === "boolean") throw "fuck typescript";
|
||||||
|
|
||||||
|
const index = dirStat != null;
|
||||||
|
|
||||||
|
if (cached && View.cache.has(path)) {
|
||||||
|
const res = View.cache.get(path)!;
|
||||||
|
if (res.path === path && res.isIndex === index && res.mtime >= mtime!) {
|
||||||
|
if (dirStat != null) {
|
||||||
|
if (res.dirMtime! >= Number(dirStat.mtime)) return res.setTimeout();
|
||||||
|
}
|
||||||
|
else return res.setTimeout();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = new constr(mtime!, uri, path, tryMtime(dirStat), normalize(dirUri + "/"), normalize(dirPath + "/"), cached);
|
||||||
|
|
||||||
|
if (cached) {
|
||||||
|
const old = View.cache.get(path);
|
||||||
|
if (old != null) old.clearTimeout();
|
||||||
|
|
||||||
|
View.cache.set(path, res);
|
||||||
|
return res.setTimeout();
|
||||||
|
}
|
||||||
|
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invoke(view: View, name: string, ...args: unknown[]) {
|
||||||
|
if (config.libs == null) throw new Error("No 'lib' folder specified");
|
||||||
|
return getPlugin(name).apply(view, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveView(path: string, cached = true, force = false): View | undefined {
|
||||||
|
path = resolve("/" + path);
|
||||||
|
|
||||||
|
const uri = resolve(config.views + path);
|
||||||
|
let res;
|
||||||
|
|
||||||
|
const dirStat = tryStatFile(uri);
|
||||||
|
|
||||||
|
if (dirStat?.isDirectory) {
|
||||||
|
res = [
|
||||||
|
of(MarkdownView, uri + "/index.md", path, undefined, uri + "/", path + "/", dirStat, cached, force),
|
||||||
|
of(PugView, uri + "/index.pug", path, undefined, uri + "/", path + "/", dirStat, cached, force),
|
||||||
|
of(JSView, uri + "/index.js", path, undefined, uri + "/", path + "/", dirStat, cached, force),
|
||||||
|
of(JSView, uri + "/index.ts", path, undefined, uri + "/", path + "/", dirStat, cached, force),
|
||||||
|
of(MarkdownView, uri + ".md", path, undefined, uri + "/", path + "/", dirStat, cached, force),
|
||||||
|
of(PugView, uri + ".pug", path, undefined, uri + "/", path + "/", dirStat, cached, force),
|
||||||
|
of(JSView, uri + ".js", path, undefined, uri + "/", path + "/", dirStat, cached, force),
|
||||||
|
of(JSView, uri + ".ts", path, undefined, uri + "/", path + "/", dirStat, cached, force),
|
||||||
|
].find(v => v != null);
|
||||||
|
if (res != null) return res;
|
||||||
|
|
||||||
|
if (config.defaultIndex != null) {
|
||||||
|
const templUri = resolve(config.views!, config.defaultIndex);
|
||||||
|
return [
|
||||||
|
of(MarkdownView, templUri + ".md", path, undefined, uri + "/", path + "/", dirStat, cached, force),
|
||||||
|
of(PugView, templUri + ".pug", path, undefined, uri + "/", path + "/", dirStat, cached, force),
|
||||||
|
of(JSView, templUri + ".js", path, undefined, uri + "/", path + "/", dirStat, cached, force),
|
||||||
|
of(JSView, templUri + ".ts", path, undefined, uri + "/", path + "/", dirStat, cached, force),
|
||||||
|
].find(v => v != null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return [
|
||||||
|
of(MarkdownView, uri + ".md", path, undefined, dirname(uri) + "/", dirname(path) + "/", undefined, cached, force),
|
||||||
|
of(PugView, uri + ".pug", path, undefined, dirname(uri) + "/", dirname(path) + "/", undefined, cached, force),
|
||||||
|
of(JSView, uri + ".js", path, undefined, dirname(uri) + "/", dirname(path) + "/", undefined, cached, force),
|
||||||
|
of(JSView, uri + ".ts", path, undefined, dirname(uri) + "/", dirname(path) + "/", undefined, cached, force),
|
||||||
|
].find(v => v != null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
export function resolveViewFromUri(uri: string, cached = true, index = true) {
|
||||||
|
uri = resolve(uri);
|
||||||
|
|
||||||
|
const views = resolve(config.views!);
|
||||||
|
if (!uri.startsWith(views + "/")) return undefined;
|
||||||
|
|
||||||
|
const getFile = (ext: string) => {
|
||||||
|
const type = getType(ext);
|
||||||
|
if (type == null) return undefined;
|
||||||
|
|
||||||
|
const path = uri.slice(views.length, -ext.length);
|
||||||
|
const dirPath = dirname(path);
|
||||||
|
const dirUri = resolve(views + "/" + dirPath);
|
||||||
|
|
||||||
|
const dirStat = tryStatFile(dirUri);
|
||||||
|
|
||||||
|
if (dirStat?.isDirectory) {
|
||||||
|
return of(type, uri, path, stat, resolve(views + "/" + path), path, dirStat, cached);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return of(type, uri, path, stat, dirUri, dirPath, undefined, cached);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const getDir = (ext: string) => {
|
||||||
|
const type = getType(ext);
|
||||||
|
if (type == null) return undefined;
|
||||||
|
|
||||||
|
let newUri = uri + "/index" + ext;
|
||||||
|
|
||||||
|
let fileStat = tryStatFile(newUri);
|
||||||
|
if (fileStat == null) {
|
||||||
|
if (config.defaultIndex != null) {
|
||||||
|
newUri = resolve(config.views + "/" + config.defaultIndex + ext);
|
||||||
|
fileStat = tryStatFile(newUri);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileStat == null) return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = uri.slice(views.length);
|
||||||
|
const dirUri = uri;
|
||||||
|
|
||||||
|
return of(type, newUri, path, fileStat, dirUri, path, stat ?? undefined, cached);
|
||||||
|
};
|
||||||
|
const getIndex = (ext: string) => {
|
||||||
|
if (!index) return undefined;
|
||||||
|
if (!uri.endsWith("/index" + ext)) return undefined;
|
||||||
|
|
||||||
|
const type = getType(ext);
|
||||||
|
if (type == null) return undefined;
|
||||||
|
|
||||||
|
const dirUri = dirname(uri);
|
||||||
|
const dirPath = dirUri.slice(views.length);
|
||||||
|
const dirStat = Deno.statSync(dirUri);
|
||||||
|
|
||||||
|
return of(type, uri, dirPath, stat, dirUri, dirPath, dirStat, cached);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stat = tryStatFile(uri);
|
||||||
|
let match;
|
||||||
|
|
||||||
|
if (stat == null) return undefined;
|
||||||
|
else if (match = uri.match(/index(\.(?:md|pug|js))$/)) return getIndex(match![1]);
|
||||||
|
else if ((match = uri.match(/\.(?:md|pug|js)$/)) && stat.isFile) return getFile(match![0]);
|
||||||
|
else if (stat.isDirectory) return getDir(".md") ?? getDir(".pug") ?? getDir(".js") ?? getDir(".ts");
|
||||||
|
else return undefined;
|
||||||
|
}
|
||||||
|
export function renderView(view: string, locals = {}, full = false, force = false, passthroughLocals = false) {
|
||||||
|
const res = resolveView(decodeURIComponent(view), true, force);
|
||||||
|
if (res == null) return undefined;
|
||||||
|
const cached = res.hasCache;
|
||||||
|
return {
|
||||||
|
text: res?.render({
|
||||||
|
...locals,
|
||||||
|
...(passthroughLocals ? {} : {
|
||||||
|
dir: res.dirPath,
|
||||||
|
dirUri: res.dirUri,
|
||||||
|
path: res.path,
|
||||||
|
uri: res.uri,
|
||||||
|
isIndex: res.isIndex,
|
||||||
|
})
|
||||||
|
}, full),
|
||||||
|
cached,
|
||||||
|
};
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user