mirror of
https://github.com/morten-olsen/morten-olsen.github.io.git
synced 2026-02-08 01:46:28 +01:00
init
This commit is contained in:
116
bin/build/index.ts
Normal file
116
bin/build/index.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { resolve } from "path";
|
||||
import { createReact } from "../resources/react";
|
||||
import { Observable, getCollectionItems } from "../observable";
|
||||
import { createPage } from "../resources/page";
|
||||
import { createArticles } from "../data/articles";
|
||||
import { Bundler } from "../bundler";
|
||||
import { forEach } from "../utils/observable";
|
||||
import { createEjs } from "../resources/ejs";
|
||||
import { createLatex } from "../resources/latex";
|
||||
import { markdownToLatex } from "../utils/markdown";
|
||||
import { createPositions } from "../data/positions";
|
||||
import { createProfile } from "../data/profile";
|
||||
import { Position } from "../../types";
|
||||
|
||||
const build = async () => {
|
||||
const bundler = new Bundler();
|
||||
const articles = createArticles({
|
||||
bundler,
|
||||
});
|
||||
const positions = createPositions({
|
||||
bundler,
|
||||
});
|
||||
const profile = createProfile({
|
||||
bundler,
|
||||
});
|
||||
|
||||
const latex = {
|
||||
article: createEjs(resolve("content/templates/latex/article.tex")),
|
||||
resume: createEjs(resolve("content/templates/latex/resume.tex")),
|
||||
};
|
||||
|
||||
const react = {
|
||||
article: createReact(resolve("content/templates/react/article.tsx")),
|
||||
frontpage: createReact(resolve("content/templates/react/frontpage.tsx")),
|
||||
};
|
||||
|
||||
const resumeProps = Observable.combine({
|
||||
articles: articles.pipe(getCollectionItems),
|
||||
positions: positions.pipe(async (positions) => {
|
||||
const result: Position[] = [];
|
||||
for (const a of positions) {
|
||||
const item = await a.data;
|
||||
const content = markdownToLatex({
|
||||
root: resolve("content"),
|
||||
content: item.raw,
|
||||
});
|
||||
result.push({
|
||||
...item,
|
||||
content,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}),
|
||||
profile,
|
||||
});
|
||||
|
||||
const resumeUrl = createLatex({
|
||||
bundler,
|
||||
path: "/resume",
|
||||
data: resumeProps,
|
||||
template: latex.resume,
|
||||
});
|
||||
|
||||
{
|
||||
const props = Observable.combine({
|
||||
articles: articles.pipe(getCollectionItems),
|
||||
positions: positions.pipe(getCollectionItems),
|
||||
profile,
|
||||
resumeUrl: new Observable(async () => resumeUrl),
|
||||
});
|
||||
createPage({
|
||||
path: "/",
|
||||
props,
|
||||
template: react.frontpage,
|
||||
bundler,
|
||||
});
|
||||
}
|
||||
|
||||
await forEach(articles, async (article) => {
|
||||
const { slug } = await article.data;
|
||||
const pdfUrl = createLatex({
|
||||
bundler,
|
||||
path: resolve("/articles", slug),
|
||||
template: latex.article,
|
||||
data: Observable.combine({
|
||||
article: article.pipe(async ({ title, cover, root, raw }) => {
|
||||
const body = markdownToLatex({
|
||||
root,
|
||||
content: raw,
|
||||
});
|
||||
return {
|
||||
title,
|
||||
body,
|
||||
cover: resolve(root, cover),
|
||||
};
|
||||
}),
|
||||
}),
|
||||
});
|
||||
const props = Observable.combine({
|
||||
article,
|
||||
profile,
|
||||
pdfUrl: new Observable(async () => pdfUrl),
|
||||
resumeUrl: new Observable(async () => resumeUrl),
|
||||
});
|
||||
createPage({
|
||||
path: `/articles/${slug}`,
|
||||
props,
|
||||
template: react.article,
|
||||
bundler,
|
||||
});
|
||||
});
|
||||
|
||||
return bundler;
|
||||
};
|
||||
|
||||
export { build };
|
||||
34
bin/bundler/index.ts
Normal file
34
bin/bundler/index.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { resolve } from "path";
|
||||
import { Observable } from "../observable";
|
||||
|
||||
type Asset = {
|
||||
content: string | Buffer;
|
||||
};
|
||||
|
||||
class Bundler {
|
||||
#assets: Map<string, Observable<Asset>>;
|
||||
|
||||
constructor() {
|
||||
this.#assets = new Map();
|
||||
}
|
||||
|
||||
public get paths() {
|
||||
return [...this.#assets.keys()];
|
||||
}
|
||||
|
||||
public register = (path: string, asset: Observable<Asset>) => {
|
||||
const realPath = resolve("/", path);
|
||||
if (!this.#assets.has(realPath)) {
|
||||
this.#assets.set(realPath, asset);
|
||||
}
|
||||
return realPath;
|
||||
};
|
||||
|
||||
public get = (path: string) => {
|
||||
const realPath = resolve("/", path);
|
||||
return this.#assets.get(realPath);
|
||||
};
|
||||
}
|
||||
|
||||
export type { Asset };
|
||||
export { Bundler };
|
||||
59
bin/data/articles/index.ts
Normal file
59
bin/data/articles/index.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { createGlob } from "../../resources/glob";
|
||||
import { createFile } from "../../resources/file";
|
||||
import grayMatter from "gray-matter";
|
||||
import { Article } from "../../../types/article";
|
||||
import { Bundler } from "../../bundler";
|
||||
import { markdownBundleImages } from "../../utils/markdown";
|
||||
import { dirname, resolve } from "path";
|
||||
import { createImage } from "../../resources/image";
|
||||
|
||||
type ArticleOptions = {
|
||||
bundler: Bundler;
|
||||
};
|
||||
|
||||
const createArticles = ({ bundler }: ArticleOptions) => {
|
||||
const files = createGlob({
|
||||
pattern: "content/articles/**/*.md",
|
||||
create: (path) => {
|
||||
const file = createFile({ path });
|
||||
const article = file.pipe(async (raw) => {
|
||||
const { data, content } = grayMatter(raw);
|
||||
const { title, slug, cover, color } = data;
|
||||
const cwd = dirname(path);
|
||||
const markdown = await markdownBundleImages({
|
||||
cwd,
|
||||
content,
|
||||
bundler,
|
||||
});
|
||||
const coverUrl = createImage({
|
||||
image: resolve(cwd, cover),
|
||||
format: "avif",
|
||||
bundler,
|
||||
});
|
||||
const thumbUrl = createImage({
|
||||
image: resolve(cwd, cover),
|
||||
format: "avif",
|
||||
width: 400,
|
||||
bundler,
|
||||
});
|
||||
const result: Article = {
|
||||
title,
|
||||
raw: content,
|
||||
cover,
|
||||
root: cwd,
|
||||
content: markdown,
|
||||
coverUrl,
|
||||
thumbUrl,
|
||||
color,
|
||||
slug,
|
||||
} as any;
|
||||
return result;
|
||||
});
|
||||
return article;
|
||||
},
|
||||
});
|
||||
|
||||
return files;
|
||||
};
|
||||
|
||||
export { createArticles };
|
||||
45
bin/data/positions/index.ts
Normal file
45
bin/data/positions/index.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { createGlob } from "../../resources/glob";
|
||||
import { createFile } from "../../resources/file";
|
||||
import grayMatter from "gray-matter";
|
||||
import { Bundler } from "../../bundler";
|
||||
import { markdownBundleImages } from "../../utils/markdown";
|
||||
import { dirname } from "path";
|
||||
import { Position } from "../../../types";
|
||||
import { Observable } from "../../observable";
|
||||
|
||||
type PositionOptions = {
|
||||
bundler: Bundler;
|
||||
};
|
||||
|
||||
const createPositions = ({ bundler }: PositionOptions) => {
|
||||
const files = createGlob<Observable<Position>>({
|
||||
pattern: "content/resume/positions/**/*.md",
|
||||
create: (path) => {
|
||||
const file = createFile({ path });
|
||||
const position = file.pipe(async (raw) => {
|
||||
const { data, content } = grayMatter(raw);
|
||||
const { title } = data;
|
||||
const cwd = dirname(path);
|
||||
const markdown = await markdownBundleImages({
|
||||
cwd,
|
||||
content,
|
||||
bundler,
|
||||
});
|
||||
const result = {
|
||||
company: data.company,
|
||||
title,
|
||||
from: data.from,
|
||||
to: data.to,
|
||||
raw: content,
|
||||
content: markdown,
|
||||
} as any;
|
||||
return result;
|
||||
});
|
||||
return position;
|
||||
},
|
||||
});
|
||||
|
||||
return files;
|
||||
};
|
||||
|
||||
export { createPositions };
|
||||
35
bin/data/profile/index.ts
Normal file
35
bin/data/profile/index.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { resolve } from "path";
|
||||
import { createFile } from "../../resources/file";
|
||||
import YAML from "yaml";
|
||||
import { Bundler } from "../../bundler";
|
||||
import { Profile } from "../../../types";
|
||||
import { createImage } from "../../resources/image";
|
||||
|
||||
type ProfileOptions = {
|
||||
bundler: Bundler;
|
||||
};
|
||||
|
||||
const createProfile = ({ bundler }: ProfileOptions) => {
|
||||
const file = createFile({
|
||||
path: resolve("content/profile.yml"),
|
||||
});
|
||||
|
||||
const profile = file.pipe(async (yaml) => {
|
||||
const data = YAML.parse(yaml);
|
||||
const imagePath = resolve("content", data.image);
|
||||
const result: Profile = {
|
||||
...data,
|
||||
imageUrl: createImage({
|
||||
image: imagePath,
|
||||
format: "avif",
|
||||
bundler,
|
||||
}),
|
||||
imagePath,
|
||||
};
|
||||
return result;
|
||||
});
|
||||
|
||||
return profile;
|
||||
};
|
||||
|
||||
export { createProfile };
|
||||
46
bin/dev/server.ts
Normal file
46
bin/dev/server.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import express, { Express } from "express";
|
||||
import { Bundler } from "../bundler";
|
||||
import { extname } from "path";
|
||||
|
||||
const createServer = (bundler: Bundler): Express => {
|
||||
const app = express();
|
||||
app.use((req, res) => {
|
||||
let path = req.path;
|
||||
let asset = bundler.get(path);
|
||||
if (!asset) {
|
||||
path = path.endsWith("/") ? path + "index.html" : path + "/index.html";
|
||||
asset = bundler.get(path);
|
||||
}
|
||||
if (asset) {
|
||||
const ext = extname(path);
|
||||
asset.data.then((data) => {
|
||||
if (ext === ".html") {
|
||||
const unsubscribe = asset!.subscribe(async () => {
|
||||
await asset?.data;
|
||||
unsubscribe();
|
||||
res.end(`<script>window.location.reload()</script>`);
|
||||
});
|
||||
res.on("close", unsubscribe);
|
||||
res.on("finish", unsubscribe);
|
||||
res.on("error", unsubscribe);
|
||||
res.writeHead(200, {
|
||||
"content-type": "text/html;charset=utf-8",
|
||||
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||
Pragma: "no-cache",
|
||||
Expires: "0",
|
||||
"keep-alive": "timeout=5, max=100",
|
||||
});
|
||||
res.write(data.content.toString().replace("</html>", ""));
|
||||
} else {
|
||||
res.send(data.content);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
res.status(404).send("Not found");
|
||||
}
|
||||
});
|
||||
|
||||
return app;
|
||||
};
|
||||
|
||||
export { createServer };
|
||||
40
bin/index.ts
Normal file
40
bin/index.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { program } from "commander";
|
||||
import { build } from "./build";
|
||||
import { createServer } from "./dev/server";
|
||||
import { dirname, join, resolve } from "path";
|
||||
import { mkdir, rm, writeFile } from "fs/promises";
|
||||
import { existsSync } from "fs";
|
||||
|
||||
const dev = program.command("dev");
|
||||
dev.action(async () => {
|
||||
const bundler = await build();
|
||||
const server = createServer(bundler);
|
||||
server.listen(3000);
|
||||
});
|
||||
|
||||
const bundle = program.command("build");
|
||||
bundle.action(async () => {
|
||||
const bundler = await build();
|
||||
const outputDir = resolve("out");
|
||||
if (existsSync(outputDir)) {
|
||||
rm(outputDir, { recursive: true });
|
||||
}
|
||||
for (let path of bundler.paths) {
|
||||
await bundler.get(path)?.data;
|
||||
}
|
||||
for (let path of bundler.paths) {
|
||||
const asset = bundler.get(path);
|
||||
if (!asset) {
|
||||
throw new Error(`Asset not found for path: ${path}`);
|
||||
}
|
||||
const content = await asset.data;
|
||||
const target = join(outputDir, path);
|
||||
const targetDir = dirname(target);
|
||||
await mkdir(targetDir, { recursive: true });
|
||||
await writeFile(target, content.content);
|
||||
console.log(`Wrote ${target}`);
|
||||
}
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
program.parse(process.argv);
|
||||
70
bin/observable/index.test.ts
Normal file
70
bin/observable/index.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { Observable } from "./observable";
|
||||
import { getCollectionItems } from "./utils";
|
||||
|
||||
describe("observable", () => {
|
||||
it("should be able to create an observable", async () => {
|
||||
const observable = new Observable(() => Promise.resolve(1));
|
||||
expect(observable).toBeDefined();
|
||||
const data = await observable.data;
|
||||
expect(data).toBe(1);
|
||||
});
|
||||
|
||||
it("should be able to combine observables", async () => {
|
||||
const observable1 = new Observable(() => Promise.resolve(1));
|
||||
const observable2 = new Observable(() => Promise.resolve(2));
|
||||
const combined = Observable.combine({ observable1, observable2 });
|
||||
const data = await combined.data;
|
||||
expect(data.observable1).toBe(1);
|
||||
expect(data.observable2).toBe(2);
|
||||
});
|
||||
|
||||
it("should be able to update observable", async () => {
|
||||
const observable = new Observable(() => Promise.resolve(1));
|
||||
const data = await observable.data;
|
||||
expect(data).toBe(1);
|
||||
observable.set(() => Promise.resolve(2));
|
||||
const data2 = await observable.data;
|
||||
expect(data2).toBe(2);
|
||||
});
|
||||
|
||||
it("should be able to extract collection items", async () => {
|
||||
const observable = new Observable(() =>
|
||||
Promise.resolve([
|
||||
new Observable(() => Promise.resolve(1)),
|
||||
new Observable(() => Promise.resolve(2)),
|
||||
new Observable(() => Promise.resolve(3)),
|
||||
])
|
||||
);
|
||||
const flatten = observable.pipe(getCollectionItems);
|
||||
const data = await flatten.data;
|
||||
expect(data).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("should update observable when subscribed", async () => {
|
||||
const observable = new Observable(() => Promise.resolve(1));
|
||||
const spy = jest.fn();
|
||||
observable.subscribe(spy);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
observable.set(() => Promise.resolve(2));
|
||||
expect(spy).toHaveBeenCalled();
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should update combined observable when subscribed", async () => {
|
||||
const observable1 = new Observable(() => Promise.resolve(1));
|
||||
const observable2 = new Observable(() => Promise.resolve(2));
|
||||
const combined = Observable.combine({ observable1, observable2 });
|
||||
const spy = jest.fn();
|
||||
const data1 = await combined.data;
|
||||
expect(data1.observable1).toBe(1);
|
||||
expect(data1.observable2).toBe(2);
|
||||
combined.subscribe(spy);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
observable2.set(() => Promise.resolve(3));
|
||||
expect(spy).toHaveBeenCalled();
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
const data2 = await combined.data;
|
||||
expect(data2.observable1).toBe(1);
|
||||
expect(data2.observable2).toBe(3);
|
||||
});
|
||||
});
|
||||
2
bin/observable/index.ts
Normal file
2
bin/observable/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Observable } from "./observable";
|
||||
export { getCollectionItems } from "./utils";
|
||||
81
bin/observable/observable.ts
Normal file
81
bin/observable/observable.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
type Observer = () => void;
|
||||
|
||||
type ObservableRecord<T extends Record<string, Observable<any>>> = {
|
||||
[K in keyof T]: T[K] extends Observable<infer U> ? U : never;
|
||||
};
|
||||
|
||||
class Observable<T> {
|
||||
#observers: Observer[] = [];
|
||||
#data?: Promise<T>;
|
||||
#loader: (current?: T) => Promise<T>;
|
||||
|
||||
constructor(loader: () => Promise<T>) {
|
||||
this.#loader = loader;
|
||||
}
|
||||
|
||||
public get ready() {
|
||||
return this.#data;
|
||||
}
|
||||
|
||||
public get data() {
|
||||
if (!this.#data) {
|
||||
this.#data = this.#loader(this.#data);
|
||||
}
|
||||
return this.#data;
|
||||
}
|
||||
|
||||
public recreate = () => {
|
||||
this.#data = undefined;
|
||||
this.notify();
|
||||
};
|
||||
|
||||
public set(loader: (current?: T) => Promise<T>) {
|
||||
this.#data = undefined;
|
||||
this.#loader = loader;
|
||||
this.notify();
|
||||
}
|
||||
|
||||
public notify = () => {
|
||||
this.#observers.forEach((observer) => observer());
|
||||
};
|
||||
|
||||
subscribe = (observer: Observer) => {
|
||||
this.#observers.push(observer);
|
||||
return () => this.unsubscribe(observer);
|
||||
};
|
||||
|
||||
unsubscribe = (observer: Observer) => {
|
||||
this.#observers = this.#observers.filter((o) => o !== observer);
|
||||
};
|
||||
|
||||
pipe = <U>(fn: (data: T) => Promise<U>) => {
|
||||
const loader = async () => fn(await this.data);
|
||||
const observable = new Observable<U>(loader);
|
||||
this.subscribe(() => {
|
||||
observable.set(loader);
|
||||
});
|
||||
return observable;
|
||||
};
|
||||
|
||||
static combine = <U extends Record<string, Observable<any>>>(
|
||||
record: U
|
||||
): Observable<ObservableRecord<U>> => {
|
||||
const loader = () =>
|
||||
Object.entries(record).reduce(
|
||||
async (accP, [key, value]) => ({
|
||||
...(await accP),
|
||||
[key]: await value.data,
|
||||
}),
|
||||
{} as any
|
||||
);
|
||||
const observable = new Observable<ObservableRecord<U>>(loader);
|
||||
Object.values(record).forEach((item) => {
|
||||
item.subscribe(async () => {
|
||||
observable.set(loader);
|
||||
});
|
||||
});
|
||||
return observable;
|
||||
};
|
||||
}
|
||||
|
||||
export { Observable };
|
||||
7
bin/observable/utils.ts
Normal file
7
bin/observable/utils.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Observable } from "./observable";
|
||||
|
||||
const getCollectionItems = async <T>(items: Observable<T>[]) => {
|
||||
return Promise.all(items.map((item) => item.data));
|
||||
};
|
||||
|
||||
export { getCollectionItems };
|
||||
13
bin/resources/ejs/index.ts
Normal file
13
bin/resources/ejs/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { createFile } from "../file";
|
||||
import ejs from "ejs";
|
||||
|
||||
const createEjs = (path: string) => {
|
||||
const file = createFile({ path });
|
||||
const template = file.pipe(async (tmpl) => {
|
||||
const compiled = ejs.compile(tmpl.toString());
|
||||
return compiled;
|
||||
});
|
||||
return template;
|
||||
};
|
||||
|
||||
export { createEjs };
|
||||
19
bin/resources/file/index.ts
Normal file
19
bin/resources/file/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { readFile } from "fs/promises";
|
||||
import { Observable } from "../../observable";
|
||||
import { watch } from "fs";
|
||||
|
||||
type FileOptions = {
|
||||
path: string;
|
||||
};
|
||||
|
||||
const createFile = ({ path }: FileOptions) => {
|
||||
const file = new Observable(async () => readFile(path, "utf-8"));
|
||||
|
||||
watch(path, () => {
|
||||
file.recreate();
|
||||
});
|
||||
|
||||
return file;
|
||||
};
|
||||
|
||||
export { createFile };
|
||||
33
bin/resources/glob/index.ts
Normal file
33
bin/resources/glob/index.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import fastGlob from "fast-glob";
|
||||
import watchGlob from "glob-watcher";
|
||||
import { Observable } from "../../observable";
|
||||
|
||||
type GlobOptions<T> = {
|
||||
cwd?: string;
|
||||
pattern: string;
|
||||
create?: (path: string) => T;
|
||||
};
|
||||
|
||||
const defaultCreate = (a: any) => a;
|
||||
|
||||
const createGlob = <T = string>({
|
||||
cwd,
|
||||
pattern,
|
||||
create = defaultCreate,
|
||||
}: GlobOptions<T>) => {
|
||||
const glob = new Observable(async () => {
|
||||
const files = await fastGlob(pattern, { cwd });
|
||||
return files.map(create);
|
||||
});
|
||||
|
||||
const watcher = watchGlob(pattern, { cwd });
|
||||
watcher.on("add", (path) => {
|
||||
glob.set((current) => Promise.resolve([...(current || []), create(path)]));
|
||||
|
||||
return glob;
|
||||
});
|
||||
|
||||
return glob;
|
||||
};
|
||||
|
||||
export { createGlob };
|
||||
40
bin/resources/image/index.ts
Normal file
40
bin/resources/image/index.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { createHash } from "crypto";
|
||||
import { Asset, Bundler } from "../../bundler";
|
||||
import { Observable } from "../../observable";
|
||||
import sharp, { FormatEnum } from "sharp";
|
||||
|
||||
type ImageOptions = {
|
||||
format: keyof FormatEnum;
|
||||
name?: string;
|
||||
image: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
bundler: Bundler;
|
||||
};
|
||||
|
||||
const createImage = (options: ImageOptions) => {
|
||||
let path =
|
||||
options.name || createHash("sha256").update(options.image).digest("hex");
|
||||
if (options.width) {
|
||||
path += `-w${options.width}`;
|
||||
}
|
||||
if (options.height) {
|
||||
path += `-h${options.height}`;
|
||||
}
|
||||
path += `.${options.format}`;
|
||||
const loader = async () => {
|
||||
const item = sharp(options.image);
|
||||
if (options.width || options.height) {
|
||||
item.resize(options.width, options.height);
|
||||
}
|
||||
item.toFormat(options.format);
|
||||
const content = await item.toBuffer();
|
||||
return {
|
||||
content,
|
||||
};
|
||||
};
|
||||
const observable = new Observable<Asset>(loader);
|
||||
return options.bundler.register(path, observable);
|
||||
};
|
||||
|
||||
export { createImage };
|
||||
29
bin/resources/latex/index.ts
Normal file
29
bin/resources/latex/index.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Asset, Bundler } from "../../bundler";
|
||||
import { Observable } from "../../observable";
|
||||
import { createEjs } from "../ejs";
|
||||
import { latexToPdf } from "./utils";
|
||||
|
||||
type LatexOptions = {
|
||||
path: string;
|
||||
bundler: Bundler;
|
||||
template: ReturnType<typeof createEjs>;
|
||||
data: Observable<any>;
|
||||
};
|
||||
const createLatex = ({ template, data, path, bundler }: LatexOptions) => {
|
||||
const pdf = Observable.combine({
|
||||
template,
|
||||
data,
|
||||
})
|
||||
.pipe(async ({ template, data }) => template(data))
|
||||
.pipe(async (latex) => {
|
||||
const pdf = await latexToPdf(latex);
|
||||
|
||||
const asset: Asset = {
|
||||
content: pdf,
|
||||
};
|
||||
return asset;
|
||||
});
|
||||
return bundler.register(`${path}.pdf`, pdf);
|
||||
};
|
||||
|
||||
export { createLatex };
|
||||
23
bin/resources/latex/utils.ts
Normal file
23
bin/resources/latex/utils.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import latex from "node-latex";
|
||||
import { Readable } from "stream";
|
||||
|
||||
const latexToPdf = (doc: string) =>
|
||||
new Promise<Buffer>((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
const input = new Readable();
|
||||
input.push(doc);
|
||||
input.push(null);
|
||||
const latexStream = latex(input);
|
||||
latexStream.on("data", (chunk) => {
|
||||
chunks.push(Buffer.from(chunk));
|
||||
});
|
||||
latexStream.on("finish", () => {
|
||||
const result = Buffer.concat(chunks);
|
||||
resolve(result);
|
||||
});
|
||||
latexStream.on("error", (err) => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
|
||||
export { latexToPdf };
|
||||
0
bin/resources/markdown/index.ts
Normal file
0
bin/resources/markdown/index.ts
Normal file
62
bin/resources/page/index.ts
Normal file
62
bin/resources/page/index.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import React, { ComponentType } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { HelmetProvider, FilledContext } from "react-helmet-async";
|
||||
import { Asset, Bundler } from "../../bundler";
|
||||
import { Observable } from "../../observable";
|
||||
import { ServerStyleSheet } from "styled-components";
|
||||
import { resolve } from "path";
|
||||
|
||||
type PageOptions = {
|
||||
path: string;
|
||||
template: Observable<ComponentType<any>>;
|
||||
props: Observable<any>;
|
||||
bundler: Bundler;
|
||||
};
|
||||
const createPage = (options: PageOptions) => {
|
||||
const data = Observable.combine({
|
||||
template: options.template,
|
||||
props: options.props,
|
||||
});
|
||||
const page = data.pipe(async ({ template, props }) => {
|
||||
const sheet = new ServerStyleSheet();
|
||||
const helmetContext: FilledContext = {} as any;
|
||||
const body = sheet.collectStyles(
|
||||
React.createElement(
|
||||
HelmetProvider,
|
||||
{ context: helmetContext },
|
||||
React.createElement(template, props)
|
||||
)
|
||||
);
|
||||
const bodyHtml = renderToStaticMarkup(body);
|
||||
const { helmet } = helmetContext;
|
||||
|
||||
const css = sheet.getStyleTags();
|
||||
const headHtml = [
|
||||
css,
|
||||
helmet.title?.toString(),
|
||||
helmet.priority?.toString(),
|
||||
helmet.meta?.toString(),
|
||||
helmet.link?.toString(),
|
||||
helmet.script?.toString(),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("");
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
${headHtml}
|
||||
</head>
|
||||
<body>
|
||||
${bodyHtml}
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
const asset: Asset = { content: html };
|
||||
return asset;
|
||||
});
|
||||
|
||||
const path = resolve("/", options.path, "index.html");
|
||||
return options.bundler.register(path, page);
|
||||
};
|
||||
|
||||
export { createPage };
|
||||
82
bin/resources/react/index.ts
Normal file
82
bin/resources/react/index.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import vm from "vm";
|
||||
import React, { ComponentType } from "react";
|
||||
import { nodeResolve } from "@rollup/plugin-node-resolve";
|
||||
import commonjs from "@rollup/plugin-commonjs";
|
||||
import json from "@rollup/plugin-json";
|
||||
import replace from "@rollup/plugin-replace";
|
||||
import sucrase from "@rollup/plugin-sucrase";
|
||||
import alias from "@rollup/plugin-alias";
|
||||
import externalGlobals from "rollup-plugin-external-globals";
|
||||
import { createScript } from "../script";
|
||||
import orgStyled from "styled-components";
|
||||
import * as styledExports from "styled-components";
|
||||
import ReactHelmetAsync from "react-helmet-async";
|
||||
import { resolve } from "path";
|
||||
|
||||
const styled = orgStyled.bind(null);
|
||||
for (let key of Object.keys(orgStyled)) {
|
||||
if (key === "default") {
|
||||
continue;
|
||||
}
|
||||
(styled as any)[key] = (orgStyled as any)[key];
|
||||
}
|
||||
for (let key of Object.keys(styledExports)) {
|
||||
if (key === "default") {
|
||||
continue;
|
||||
}
|
||||
(styled as any)[key] = (styledExports as any)[key];
|
||||
}
|
||||
|
||||
const createReact = <TProps = any>(path: string) => {
|
||||
const script = createScript({
|
||||
path,
|
||||
format: "cjs",
|
||||
plugins: [
|
||||
replace({
|
||||
preventAssignment: true,
|
||||
"process.env.NODE_ENV": JSON.stringify("production"),
|
||||
}),
|
||||
alias({
|
||||
entries: [
|
||||
{ find: "@", replacement: resolve("content/templates/react") },
|
||||
],
|
||||
}),
|
||||
sucrase({
|
||||
exclude: ["node_modules/**"],
|
||||
transforms: ["jsx", "typescript"],
|
||||
}),
|
||||
nodeResolve({
|
||||
browser: true,
|
||||
preferBuiltins: false,
|
||||
extensions: [".js", ".ts", ".tsx"],
|
||||
}),
|
||||
json(),
|
||||
commonjs({
|
||||
include: /node_modules/,
|
||||
}),
|
||||
externalGlobals({
|
||||
react: "React",
|
||||
"styled-components": "StyledComponents",
|
||||
"react-helmet-async": "ReactHelmetAsync",
|
||||
}),
|
||||
],
|
||||
});
|
||||
const template = script.pipe<ComponentType<TProps>>(async () => {
|
||||
const scriptContent = await script.data;
|
||||
const exports: any = {};
|
||||
const module = { exports };
|
||||
const globals = {
|
||||
module,
|
||||
exports,
|
||||
React,
|
||||
StyledComponents: styled,
|
||||
ReactHelmetAsync,
|
||||
};
|
||||
vm.runInNewContext(scriptContent, globals);
|
||||
return module.exports;
|
||||
});
|
||||
|
||||
return template;
|
||||
};
|
||||
|
||||
export { createReact };
|
||||
52
bin/resources/script/index.ts
Normal file
52
bin/resources/script/index.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Observable } from "../../observable";
|
||||
import { InputPluginOption, ModuleFormat, watch } from "rollup";
|
||||
|
||||
type ScriptOptions = {
|
||||
path: string;
|
||||
format: ModuleFormat;
|
||||
plugins?: InputPluginOption;
|
||||
};
|
||||
|
||||
const build = (options: ScriptOptions, update: (code: string) => void) =>
|
||||
new Promise<string>((resolve, reject) => {
|
||||
let compiled = false;
|
||||
const watcher = watch({
|
||||
input: options.path,
|
||||
plugins: options.plugins,
|
||||
onwarn: () => { },
|
||||
output: {
|
||||
format: options.format,
|
||||
},
|
||||
watch: {
|
||||
skipWrite: true,
|
||||
},
|
||||
});
|
||||
|
||||
watcher.on("event", async (event) => {
|
||||
if (event.code === "BUNDLE_END") {
|
||||
const { output } = await event.result.generate({
|
||||
format: options.format,
|
||||
});
|
||||
const { code } = output[0];
|
||||
if (!compiled) {
|
||||
resolve(code);
|
||||
compiled = true;
|
||||
} else {
|
||||
update(code);
|
||||
}
|
||||
}
|
||||
if (event.code === "ERROR") {
|
||||
reject(event.error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const createScript = (options: ScriptOptions) => {
|
||||
const script: Observable<string> = new Observable(() =>
|
||||
build(options, (code) => script.set(() => Promise.resolve(code)))
|
||||
);
|
||||
|
||||
return script;
|
||||
};
|
||||
|
||||
export { createScript };
|
||||
56
bin/utils/markdown/index.ts
Normal file
56
bin/utils/markdown/index.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { resolve } from "path";
|
||||
import { decode } from "html-entities";
|
||||
import { marked } from "marked";
|
||||
import remark from "remark";
|
||||
import visit from "unist-util-visit";
|
||||
import { Bundler } from "../../bundler";
|
||||
import { createImage } from "../../resources/image";
|
||||
import { renderer } from "./latex";
|
||||
|
||||
type MarkdownBundleImagesOptions = {
|
||||
cwd: string;
|
||||
content: string;
|
||||
bundler: Bundler;
|
||||
};
|
||||
|
||||
const markdownBundleImages = async ({
|
||||
bundler,
|
||||
cwd,
|
||||
content,
|
||||
}: MarkdownBundleImagesOptions) => {
|
||||
const result = await remark()
|
||||
.use(() => (tree) => {
|
||||
visit(tree, "image", (node) => {
|
||||
if (!("url" in node)) {
|
||||
return;
|
||||
}
|
||||
const url = node.url as string;
|
||||
const path = resolve(cwd, url);
|
||||
const image = createImage({
|
||||
image: path,
|
||||
bundler,
|
||||
format: "webp",
|
||||
});
|
||||
const newUrl = image;
|
||||
node.url = newUrl;
|
||||
});
|
||||
})
|
||||
.process(content);
|
||||
return String(result);
|
||||
};
|
||||
|
||||
type MarkdownToLatexOptions = {
|
||||
root: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
const markdownToLatex = ({ root, content }: MarkdownToLatexOptions) => {
|
||||
const render: any = {
|
||||
...renderer(0),
|
||||
};
|
||||
const latex = marked(content, {
|
||||
renderer: render,
|
||||
});
|
||||
return decode(latex);
|
||||
};
|
||||
export { markdownBundleImages, markdownToLatex };
|
||||
91
bin/utils/markdown/latex.ts
Normal file
91
bin/utils/markdown/latex.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { decode } from "html-entities";
|
||||
import { existsSync } from "fs";
|
||||
|
||||
const latexTypes = ["", "section", "subsection", "paragraph", "subparagraph"];
|
||||
|
||||
const sanitize = (text?: string) => {
|
||||
if (!text) {
|
||||
return "";
|
||||
}
|
||||
return decode(text)
|
||||
.replace("&", "\\&")
|
||||
.replace("_", "\\_")
|
||||
.replace(/([^\\])\}/g, "$1\\}")
|
||||
.replace(/([^\\])\{/g, "$1\\{")
|
||||
.replace(/[^\\]\[/g, "\\[")
|
||||
.replace(/#/g, "\\#");
|
||||
};
|
||||
|
||||
type Renderer = (depth: number) => {
|
||||
heading?: (text: string, depth: number) => string;
|
||||
code?: (input: string) => string;
|
||||
text?: (input: string) => string;
|
||||
paragraph?: (input: string) => string;
|
||||
list?: (input: string) => string;
|
||||
listitem?: (input: string) => string;
|
||||
link?: (href: string, text: string) => string;
|
||||
strong?: (text: string) => string;
|
||||
em?: (text: string) => string;
|
||||
codespan?: (code: string) => string;
|
||||
image?: (link: string) => string;
|
||||
};
|
||||
|
||||
const renderer = (outerDepth: number) => ({
|
||||
heading: (text: string, depth: number) => {
|
||||
return `\\${latexTypes[outerDepth + depth]}{${sanitize(text)}}\n\n`;
|
||||
},
|
||||
code: (input: string) => {
|
||||
return `
|
||||
\\begin{lstlisting}
|
||||
${input}
|
||||
\\end{lstlisting}
|
||||
`;
|
||||
},
|
||||
text: (input: string) => {
|
||||
return sanitize(input);
|
||||
},
|
||||
blockquote: (input: string) => {
|
||||
return sanitize(input);
|
||||
},
|
||||
paragraph: (input: string) => {
|
||||
return `${input}\n\n`;
|
||||
},
|
||||
list: (input: string) => {
|
||||
return `
|
||||
\\begin{itemize}
|
||||
${input}
|
||||
\\end{itemize}
|
||||
`;
|
||||
},
|
||||
listitem: (input: string) => {
|
||||
return `\\item{${input}}`;
|
||||
},
|
||||
link: (href: string, text: string) => {
|
||||
if (!text || text === href) {
|
||||
return `\\url{${sanitize(href)}}`;
|
||||
}
|
||||
return `${sanitize(text)} (\\url{${sanitize(href)}})`;
|
||||
},
|
||||
strong: (text: string) => {
|
||||
return `\\textbf{${sanitize(text)}}`;
|
||||
},
|
||||
em: (text: string) => {
|
||||
return `\\textbf{${sanitize(text)}}`;
|
||||
},
|
||||
codespan: (code: string) => {
|
||||
return `\\texttt{${sanitize(code)}}`;
|
||||
},
|
||||
image: (link: string) => {
|
||||
if (!existsSync(link)) {
|
||||
return "Online image not supported";
|
||||
}
|
||||
return `\\begin{figure}[h!]
|
||||
\\includegraphics[width=0.5\\textwidth]{${link}}
|
||||
\\centering
|
||||
\\end{figure}
|
||||
`;
|
||||
},
|
||||
});
|
||||
|
||||
export type { Renderer };
|
||||
export { sanitize, renderer };
|
||||
27
bin/utils/observable/index.ts
Normal file
27
bin/utils/observable/index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Observable } from "../../observable";
|
||||
|
||||
const forEach = async <T extends Observable<any[]>>(
|
||||
observable: T,
|
||||
fn: (
|
||||
value: T extends Observable<infer U>
|
||||
? U extends Array<infer A>
|
||||
? A
|
||||
: never
|
||||
: never
|
||||
) => Promise<void>
|
||||
) => {
|
||||
const knownValues = new Set();
|
||||
const update = async () => {
|
||||
for (let value of await observable.data) {
|
||||
if (knownValues.has(value)) {
|
||||
continue;
|
||||
}
|
||||
await fn(value);
|
||||
knownValues.add(value);
|
||||
}
|
||||
};
|
||||
await update();
|
||||
observable.subscribe(update);
|
||||
};
|
||||
|
||||
export { forEach };
|
||||
Reference in New Issue
Block a user