.
This commit is contained in:
commit
016889f6ef
20 changed files with 3277 additions and 0 deletions
28
.eslintrc.yml
Normal file
28
.eslintrc.yml
Normal file
|
@ -0,0 +1,28 @@
|
|||
parser: "@typescript-eslint/parser"
|
||||
env:
|
||||
es6: true
|
||||
node: true
|
||||
parserOptions:
|
||||
ecmaVersion: 2020
|
||||
sourceType: "module"
|
||||
extends:
|
||||
- prettier
|
||||
- plugin:prettier/recommended
|
||||
- "plugin:@typescript-eslint/recommended"
|
||||
rules:
|
||||
"@typescript-eslint/no-unused-vars":
|
||||
- error
|
||||
- vars: all
|
||||
args: after-used
|
||||
argsIgnorePattern: ^_
|
||||
varsIgnorePattern: ^_
|
||||
ignoreRestSiblings: true
|
||||
"@typescript-eslint/no-empty-interface": off
|
||||
"@typescript-eslint/no-explicit-any": off
|
||||
"@typescript-eslint/explicit-function-return-type":
|
||||
- warn
|
||||
- allowExpressions: false
|
||||
allowTypedFunctionExpressions: true
|
||||
allowHigherOrderFunctions: true
|
||||
"@typescript-eslint/camelcase": off
|
||||
"@typescript-eslint/no-use-before-define": off
|
21
.gitignore
vendored
Normal file
21
.gitignore
vendored
Normal file
|
@ -0,0 +1,21 @@
|
|||
/*
|
||||
|
||||
# editors
|
||||
!/.vscode
|
||||
|
||||
# git
|
||||
!/.gitignore
|
||||
|
||||
# makefile
|
||||
!/makefile
|
||||
|
||||
# config
|
||||
!/.eslintrc.yml
|
||||
!/tsconfig.json
|
||||
|
||||
# node modules
|
||||
!/package.json
|
||||
!/package-lock.json
|
||||
|
||||
# sources
|
||||
!/src
|
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"editor.tabSize": 2
|
||||
}
|
6
makefile
Normal file
6
makefile
Normal file
|
@ -0,0 +1,6 @@
|
|||
|
||||
ts-w:
|
||||
npx tsc-watch --onSuccess "node target/scripts/main.mjs"
|
||||
|
||||
clean:
|
||||
rm -rf target
|
2821
package-lock.json
generated
Normal file
2821
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
13
package.json
Normal file
13
package.json
Normal file
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"devDependencies": {
|
||||
"@types/node": "^17.0.21",
|
||||
"@typescript-eslint/eslint-plugin": "^5.14.0",
|
||||
"@typescript-eslint/parser": "^5.14.0",
|
||||
"eslint": "^8.10.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-plugin-prettier": "^4.0.0",
|
||||
"prettier": "^2.5.1",
|
||||
"tsc-watch": "^4.6.0",
|
||||
"typescript": "^4.6.2"
|
||||
}
|
||||
}
|
12
src/components/layout.mts
Normal file
12
src/components/layout.mts
Normal file
|
@ -0,0 +1,12 @@
|
|||
import { AnyNode, Elem } from "../ren/nodes.mjs";
|
||||
|
||||
export async function Layout(page: AnyNode): Promise<Elem> {
|
||||
return new Elem("html")
|
||||
.withAttr("lang", "ru")
|
||||
.withChild(
|
||||
new Elem("head")
|
||||
.withChild(new Elem("meta").withAttr("charset", "utf-8"))
|
||||
.withChild(new Elem("title").withText("hello world"))
|
||||
)
|
||||
.withChild(new Elem("body").withChild(page));
|
||||
}
|
25
src/components/page_layout.mts
Normal file
25
src/components/page_layout.mts
Normal file
|
@ -0,0 +1,25 @@
|
|||
import { AnyNode, Elem, Frag } from "../ren/nodes.mjs";
|
||||
|
||||
export async function PageLayout(children: AnyNode[]): Promise<Frag> {
|
||||
return new Frag()
|
||||
.withChild(Header())
|
||||
.withChild(
|
||||
new Elem("div").withAttr("class", "content").withChildren(children)
|
||||
)
|
||||
.withChild(Footer());
|
||||
}
|
||||
|
||||
export function Header(): Elem {
|
||||
return new Elem("header").withChild(HeaderNav());
|
||||
}
|
||||
|
||||
export function HeaderNav(): Elem {
|
||||
return new Elem("nav").withChildren([
|
||||
new Elem("a").withAttr("href", "/").withText("About"),
|
||||
new Elem("a").withAttr("href", "/works").withText("Works"),
|
||||
]);
|
||||
}
|
||||
|
||||
export function Footer(): Elem {
|
||||
return new Elem("footer").withAttr("class", "footer").withText("footer");
|
||||
}
|
11
src/config.mts
Normal file
11
src/config.mts
Normal file
|
@ -0,0 +1,11 @@
|
|||
export function createConfig(): Config {
|
||||
return { server: { port: 30000 } };
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
server: ServerConfig;
|
||||
}
|
||||
|
||||
export interface ServerConfig {
|
||||
port: number;
|
||||
}
|
13
src/lang.mts
Normal file
13
src/lang.mts
Normal file
|
@ -0,0 +1,13 @@
|
|||
export function isNil<T>(v: Nilable<T>): v is Nil {
|
||||
return v == null;
|
||||
}
|
||||
|
||||
export type Nullable<T> = T | null;
|
||||
|
||||
export type Nilable<T> = T | Nil;
|
||||
|
||||
export type Nil = null | undefined;
|
||||
|
||||
export function isBool(v: unknown): v is boolean {
|
||||
return typeof v === "boolean";
|
||||
}
|
7
src/log.mts
Normal file
7
src/log.mts
Normal file
|
@ -0,0 +1,7 @@
|
|||
export function info(...args: unknown[]): void {
|
||||
console.log("[INFO]", ...args);
|
||||
}
|
||||
|
||||
export function debug(...args: unknown[]): void {
|
||||
console.log("[DEBUG]", ...args);
|
||||
}
|
9
src/main.mts
Normal file
9
src/main.mts
Normal file
|
@ -0,0 +1,9 @@
|
|||
import { createConfig } from "./config.mjs";
|
||||
import { createServer } from "./server.mjs";
|
||||
|
||||
main();
|
||||
|
||||
function main(): void {
|
||||
const config = createConfig();
|
||||
createServer(config.server);
|
||||
}
|
120
src/ren/nodes.mts
Normal file
120
src/ren/nodes.mts
Normal file
|
@ -0,0 +1,120 @@
|
|||
import { isNil, Nilable } from "../lang.mjs";
|
||||
|
||||
export type AnyNode = AnySyncNode | AnyAsyncNode;
|
||||
export type AnyAsyncNode = Promise<AnySyncNode>;
|
||||
export type AnySyncNode = TextNode | Elem | Frag;
|
||||
|
||||
export class TextNode extends String {}
|
||||
|
||||
export function F(children: AnyNode[]): Frag {
|
||||
return new Frag().withChildren(children);
|
||||
}
|
||||
|
||||
export class Frag {
|
||||
#children: Nilable<AnyNode[]>;
|
||||
|
||||
constructor() {
|
||||
this.#children = undefined;
|
||||
}
|
||||
|
||||
get children(): Nilable<AnyNode[]> {
|
||||
return this.#children;
|
||||
}
|
||||
|
||||
withText(text: string): this {
|
||||
this.addText(text);
|
||||
return this;
|
||||
}
|
||||
|
||||
addText(text: string): void {
|
||||
this.addChild(new TextNode(text));
|
||||
}
|
||||
|
||||
maybeWithChildren(nodes?: Nilable<AnyNode[]>): this {
|
||||
if (isNil(nodes)) return this;
|
||||
return this.withChildren(nodes);
|
||||
}
|
||||
|
||||
withChildren(nodes: AnyNode[]): this {
|
||||
nodes.forEach((n) => this.addChild(n));
|
||||
return this;
|
||||
}
|
||||
|
||||
withChild(node: AnyNode): this {
|
||||
this.addChild(node);
|
||||
return this;
|
||||
}
|
||||
|
||||
addChild(node: AnyNode): void {
|
||||
if (isNil(this.#children)) this.#children = [];
|
||||
this.#children.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
export function E(
|
||||
tagName: string,
|
||||
attrs: ElemAttrs,
|
||||
children?: Nilable<AnyNode[]>
|
||||
): Elem {
|
||||
return new Elem(tagName).withAttrs(attrs).maybeWithChildren(children);
|
||||
}
|
||||
|
||||
export type ElemAttrs = Record<string, unknown>;
|
||||
|
||||
export class Elem extends Frag {
|
||||
#tagName: string;
|
||||
#attrs: ElemAttrs;
|
||||
#isSelfClosed: boolean;
|
||||
|
||||
constructor(tagName: string) {
|
||||
super();
|
||||
this.#tagName = tagName;
|
||||
this.#attrs = {};
|
||||
this.#isSelfClosed = selfClosedTagNames.has(tagName);
|
||||
}
|
||||
|
||||
get tagName(): string {
|
||||
return this.#tagName;
|
||||
}
|
||||
|
||||
get attrs(): Record<string, unknown> {
|
||||
return this.#attrs;
|
||||
}
|
||||
|
||||
withAttrs(attrs: Record<string, unknown>): Elem {
|
||||
Object.entries(attrs).forEach(([key, value]) => this.addAttr(key, value));
|
||||
return this;
|
||||
}
|
||||
|
||||
withAttr(name: string, value: unknown): Elem {
|
||||
this.addAttr(name, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
addAttr(name: string, value: unknown): void {
|
||||
this.#attrs[name] = value;
|
||||
}
|
||||
|
||||
addChild(node: AnySyncNode): void {
|
||||
if (this.#isSelfClosed)
|
||||
throw new Error("You cannot add child to self closed element");
|
||||
super.addChild(node);
|
||||
}
|
||||
}
|
||||
|
||||
const selfClosedTagNames = new Set([
|
||||
"area",
|
||||
"base",
|
||||
"br",
|
||||
"col",
|
||||
"embed",
|
||||
"hr",
|
||||
"img",
|
||||
"input",
|
||||
"link",
|
||||
"meta",
|
||||
"param",
|
||||
"source",
|
||||
"track",
|
||||
"wbr",
|
||||
]);
|
58
src/ren/str.mts
Normal file
58
src/ren/str.mts
Normal file
|
@ -0,0 +1,58 @@
|
|||
import { Renderer } from "./types.mjs";
|
||||
import { isBool, isNil, Nullable } from "../lang.mjs";
|
||||
import { AnyNode, Elem, Frag, TextNode } from "./nodes.mjs";
|
||||
|
||||
export class StrRenderer implements Renderer<string> {
|
||||
async render(node: Elem | Promise<Elem>): Promise<string> {
|
||||
return encodeNode(await node);
|
||||
}
|
||||
}
|
||||
|
||||
async function encodeAnyNode(node: AnyNode): Promise<string> {
|
||||
const syncNode = await node;
|
||||
return syncNode instanceof TextNode
|
||||
? encodeTextNode(syncNode)
|
||||
: encodeNode(syncNode);
|
||||
}
|
||||
|
||||
function encodeTextNode(node: TextNode): string {
|
||||
return String(node);
|
||||
}
|
||||
|
||||
async function encodeNode(node: Elem | Frag): Promise<string> {
|
||||
const encodedChildren = isNil(node.children)
|
||||
? undefined
|
||||
: await Promise.all(node.children.map(encodeAnyNode));
|
||||
return node instanceof Elem
|
||||
? encodeHtmlElement(node.tagName, node.attrs, encodedChildren)
|
||||
: encodeHtmlFragment(encodedChildren);
|
||||
}
|
||||
|
||||
function encodeHtmlFragment(children?: string[]): string {
|
||||
return children?.join("") ?? "";
|
||||
}
|
||||
|
||||
function encodeHtmlElement(
|
||||
tagName: string,
|
||||
attrs?: Record<string, unknown>,
|
||||
children?: string[]
|
||||
): string {
|
||||
const open = `<${tagName} ${encodeAttrs(attrs)}>`;
|
||||
if (isNil(children)) return open;
|
||||
return `${open}${children.join("")}</${tagName}>`;
|
||||
}
|
||||
|
||||
function encodeAttrs(attrs?: Record<string, unknown>): Nullable<string> {
|
||||
if (!attrs) return "";
|
||||
|
||||
return Object.entries(attrs)
|
||||
.map(([key, value]) => encodeAttr(key, value))
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function encodeAttr(key: string, value: unknown): Nullable<string> {
|
||||
if (isNil(value)) return null;
|
||||
if (isBool(value)) return value ? key : null;
|
||||
return `${key}="${value}"`;
|
||||
}
|
5
src/ren/types.mts
Normal file
5
src/ren/types.mts
Normal file
|
@ -0,0 +1,5 @@
|
|||
import { Elem } from "./nodes.mjs";
|
||||
|
||||
export interface Renderer<T> {
|
||||
render(node: Elem | Promise<Elem>): Promise<T>;
|
||||
}
|
86
src/server.mts
Normal file
86
src/server.mts
Normal file
|
@ -0,0 +1,86 @@
|
|||
import * as http from "http";
|
||||
import { Layout } from "./components/layout.mjs";
|
||||
import { ServerConfig } from "./config.mjs";
|
||||
import { debug, info } from "./log.mjs";
|
||||
import { StrRenderer } from "./ren/str.mjs";
|
||||
import { AboutPage } from "./views/about.mjs";
|
||||
import { E404 } from "./views/e404.mjs";
|
||||
import { WorksPage } from "./views/works.mjs";
|
||||
|
||||
export function createServer(cfg: ServerConfig): void {
|
||||
const server = http.createServer(handleHttpReq);
|
||||
server.listen(cfg.port, () => {
|
||||
info("[server]", `Server listening at http://localhost:${cfg.port}`);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleHttpReq(
|
||||
httpReq: http.IncomingMessage,
|
||||
httpRes: http.ServerResponse
|
||||
): Promise<void> {
|
||||
try {
|
||||
const req = tryIntoAppServerRequest(httpReq);
|
||||
|
||||
debug("[server]", { req });
|
||||
|
||||
const ren = new StrRenderer();
|
||||
if (/^[/](?:about[/]?)?$/.test(req.url)) {
|
||||
httpRes
|
||||
.writeHead(200, { "content-type": "text/html" })
|
||||
.end(await ren.render(Layout(AboutPage())));
|
||||
} else if (/^[/]works[/]?/.test(req.url)) {
|
||||
httpRes
|
||||
.writeHead(200, { "content-type": "text/html" })
|
||||
.end(await ren.render(Layout(WorksPage())));
|
||||
} else {
|
||||
httpRes
|
||||
.writeHead(404, { "content-type": "text/html" })
|
||||
.end(await ren.render(Layout(E404())));
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof InvalidServerRequest) {
|
||||
httpRes.writeHead(400).end("Bad request");
|
||||
} else if (err instanceof UnsupportedRestMethod) {
|
||||
httpRes.writeHead(405).end("Method not allowed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function tryIntoAppServerRequest(
|
||||
req: http.IncomingMessage
|
||||
): ServerRequest {
|
||||
if (!req.method || !req.url) throw new InvalidServerRequest();
|
||||
|
||||
return {
|
||||
method: tryIntoAppRestMethod(req.method),
|
||||
url: req.url,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ServerRequest {
|
||||
method: RestMethod;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export class InvalidServerRequest extends Error {}
|
||||
|
||||
export function tryIntoAppRestMethod(rest: string): RestMethod {
|
||||
switch (rest.toUpperCase()) {
|
||||
case "GET":
|
||||
return RestMethod.Get;
|
||||
default:
|
||||
throw new UnsupportedRestMethod();
|
||||
}
|
||||
}
|
||||
|
||||
export enum RestMethod {
|
||||
Get = "GET",
|
||||
}
|
||||
|
||||
export class UnsupportedRestMethod extends Error {}
|
||||
|
||||
export interface ServerResponse {
|
||||
statusCode?: number;
|
||||
headers?: Headers;
|
||||
body: string;
|
||||
}
|
6
src/views/about.mts
Normal file
6
src/views/about.mts
Normal file
|
@ -0,0 +1,6 @@
|
|||
import { PageLayout } from "../components/page_layout.mjs";
|
||||
import { AnyAsyncNode, Elem } from "../ren/nodes.mjs";
|
||||
|
||||
export async function AboutPage(): AnyAsyncNode {
|
||||
return PageLayout([new Elem("p").withText("Привет мир")]);
|
||||
}
|
6
src/views/e404.mts
Normal file
6
src/views/e404.mts
Normal file
|
@ -0,0 +1,6 @@
|
|||
import { PageLayout } from "../components/page_layout.mjs";
|
||||
import { AnyAsyncNode, Elem } from "../ren/nodes.mjs";
|
||||
|
||||
export async function E404(): AnyAsyncNode {
|
||||
return PageLayout([new Elem("p").withText("Page not found")]);
|
||||
}
|
6
src/views/works.mts
Normal file
6
src/views/works.mts
Normal file
|
@ -0,0 +1,6 @@
|
|||
import { PageLayout } from "../components/page_layout.mjs";
|
||||
import { AnyAsyncNode, Elem } from "../ren/nodes.mjs";
|
||||
|
||||
export async function WorksPage(): AnyAsyncNode {
|
||||
return PageLayout([new Elem("p").withText("Works")]);
|
||||
}
|
21
tsconfig.json
Normal file
21
tsconfig.json
Normal file
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"outDir": "target/scripts",
|
||||
"target": "esnext",
|
||||
"module": "esnext",
|
||||
"lib": ["dom", "esnext"],
|
||||
"moduleResolution": "node",
|
||||
"rootDir": "src",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"suppressImplicitAnyIndexErrors": true,
|
||||
"skipLibCheck": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"noImplicitReturns": true,
|
||||
"noImplicitThis": true,
|
||||
"noImplicitAny": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.mts"
|
||||
],
|
||||
}
|
Loading…
Reference in a new issue