This commit is contained in:
Morten Olsen
2023-03-28 08:10:46 +02:00
parent 9b1a067d56
commit 7adf03c83f
44 changed files with 1780 additions and 411 deletions

12
.eslintrc Normal file
View File

@@ -0,0 +1,12 @@
{
"extends": "@react-native-community",
"rules": {
"react/react-in-jsx-scope": 0,
"prettier/prettier": [
"error",
{
"singleQuote": true
}
]
}
}

14
.prettierrc.json Normal file
View File

@@ -0,0 +1,14 @@
{
"semi": true,
"tabWidth": 2,
"useTabs": false,
"singleQuote": true,
"jsxSingleQuote": false,
"bracketSameLine": false,
"printWidth": 100,
"arrowParens": "always",
"htmlWhitespaceSensitivity": "css",
"bracketSpacing": true,
"quoteProps": "as-needed",
"trailingComma": "all"
}

31
bin/build/data.ts Normal file
View File

@@ -0,0 +1,31 @@
import { Config } from '../../types/config';
import { Bundler } from '../bundler';
import { createArticles } from '../data/articles';
import { createPositions } from '../data/positions';
import { createProfile } from '../data/profile';
type GetDataOptions = {
cwd: string;
config: Config;
bundler: Bundler;
};
const getData = ({ cwd, config, bundler }: GetDataOptions) => ({
articles: createArticles({
cwd,
pattern: config.articles.pattern,
bundler,
}),
positions: createPositions({
cwd,
pattern: config.positions.pattern,
bundler,
}),
profile: createProfile({
cwd,
path: config.profile.path,
bundler,
}),
});
export { getData };

View File

@@ -1,47 +1,33 @@
import { resolve } from "path"; import { resolve } from 'path';
import { createReact } from "../resources/react"; import { Observable, getCollectionItems } from '../observable';
import { Observable, getCollectionItems } from "../observable"; import { createPage } from '../resources/page';
import { createPage } from "../resources/page"; import { Bundler } from '../bundler';
import { createArticles } from "../data/articles"; import { forEach } from '../utils/observable';
import { Bundler } from "../bundler"; import { createLatex } from '../resources/latex';
import { forEach } from "../utils/observable"; import { markdownToLatex } from '../utils/markdown';
import { createEjs } from "../resources/ejs"; import { Position } from '../../types';
import { createLatex } from "../resources/latex"; import { Config } from '../../types/config';
import { markdownToLatex } from "../utils/markdown"; import { getTemplates } from './templates';
import { createPositions } from "../data/positions"; import { getData } from './data';
import { createProfile } from "../data/profile";
import { Position } from "../../types";
const build = async () => { const build = async (cwd: string, config: Config) => {
const bundler = new Bundler(); const bundler = new Bundler();
const articles = createArticles({ const data = getData({
bundler, cwd,
}); config,
const positions = createPositions({
bundler,
});
const profile = createProfile({
bundler, bundler,
}); });
const templates = getTemplates(cwd, config);
const latex = { const resumeData = Observable.combine({
article: createEjs(resolve("content/templates/latex/article.tex")), articles: data.articles.pipe(getCollectionItems),
resume: createEjs(resolve("content/templates/latex/resume.tex")), // TODO: collection observer
}; positions: data.positions.pipe(async (positions) => {
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[] = []; const result: Position[] = [];
for (const a of positions) { for (const a of positions) {
const item = await a.data; const item = await a.data;
const content = markdownToLatex({ const content = markdownToLatex({
root: resolve("content"), root: resolve('content'),
content: item.raw, content: item.raw,
}); });
result.push({ result.push({
@@ -51,38 +37,43 @@ const build = async () => {
} }
return result; return result;
}), }),
profile, profile: data.profile,
});
resumeData.subscribe(() => {
console.log('resume');
}); });
const resumeUrl = createLatex({ const resumeUrl = createLatex({
bundler, bundler,
path: "/resume", path: '/resume',
data: resumeProps, data: resumeData,
template: latex.resume, template: templates.latex.resume,
}); });
{ {
const props = Observable.combine({ const props = Observable.combine({
articles: articles.pipe(getCollectionItems), articles: data.articles.pipe(getCollectionItems),
positions: positions.pipe(getCollectionItems), positions: data.positions.pipe(getCollectionItems),
profile, profile: data.profile,
resumeUrl: new Observable(async () => resumeUrl), resumeUrl: Observable.link([resumeUrl.item], async () => resumeUrl.url),
}); });
createPage({ createPage({
path: "/", path: '/',
props, props,
template: react.frontpage, template: templates.react.frontpage,
bundler, bundler,
}); });
} }
await forEach(articles, async (article) => { await forEach(data.articles, async (article) => {
const { slug } = await article.data; const { slug } = await article.data;
const pdfUrl = createLatex({ const pdf = createLatex({
bundler, bundler,
path: resolve("/articles", slug), path: resolve('/articles', slug),
template: latex.article, template: templates.latex.article,
data: Observable.combine({ data: Observable.combine({
profile: data.profile,
article: article.pipe(async ({ title, cover, root, raw }) => { article: article.pipe(async ({ title, cover, root, raw }) => {
const body = markdownToLatex({ const body = markdownToLatex({
root, root,
@@ -98,14 +89,16 @@ const build = async () => {
}); });
const props = Observable.combine({ const props = Observable.combine({
article, article,
profile, profile: data.profile,
pdfUrl: new Observable(async () => pdfUrl), pdfUrl: Observable.link([pdf.item], async () => pdf.url),
resumeUrl: new Observable(async () => resumeUrl), });
article.subscribe(() => {
console.log('updated', slug);
}); });
createPage({ createPage({
path: `/articles/${slug}`, path: `/articles/${slug}`,
props, props,
template: react.article, template: templates.react.article,
bundler, bundler,
}); });
}); });

17
bin/build/templates.ts Normal file
View File

@@ -0,0 +1,17 @@
import { resolve } from 'path';
import { Config } from '../../types/config';
import { createEjs } from '../resources/ejs';
import { createReact } from '../resources/react';
const getTemplates = (cwd: string, config: Config) => ({
latex: {
article: createEjs(resolve(cwd, config.articles.latex.template)),
resume: createEjs(resolve(cwd, config.resume.latex.template)),
},
react: {
article: createReact(resolve(cwd, config.articles.react.template)),
frontpage: createReact(resolve(cwd, config.frontpage.react.template)),
},
});
export { getTemplates };

View File

@@ -1,5 +1,5 @@
import { resolve } from "path"; import { resolve } from 'path';
import { Observable } from "../observable"; import { Observable } from '../observable';
type Asset = { type Asset = {
content: string | Buffer; content: string | Buffer;
@@ -17,7 +17,7 @@ class Bundler {
} }
public register = (path: string, asset: Observable<Asset>) => { public register = (path: string, asset: Observable<Asset>) => {
const realPath = resolve("/", path); const realPath = resolve('/', path);
if (!this.#assets.has(realPath)) { if (!this.#assets.has(realPath)) {
this.#assets.set(realPath, asset); this.#assets.set(realPath, asset);
} }
@@ -25,7 +25,7 @@ class Bundler {
}; };
public get = (path: string) => { public get = (path: string) => {
const realPath = resolve("/", path); const realPath = resolve('/', path);
return this.#assets.get(realPath); return this.#assets.get(realPath);
}; };
} }

View File

@@ -1,19 +1,22 @@
import { createGlob } from "../../resources/glob"; import { createGlob } from '../../resources/glob';
import { createFile } from "../../resources/file"; import { createFile } from '../../resources/file';
import grayMatter from "gray-matter"; import grayMatter from 'gray-matter';
import { Article } from "../../../types/article"; import { Article } from '../../../types/article';
import { Bundler } from "../../bundler"; import { Bundler } from '../../bundler';
import { markdownBundleImages } from "../../utils/markdown"; import { markdownBundleImages } from '../../utils/markdown';
import { dirname, resolve } from "path"; import { dirname, resolve } from 'path';
import { createImage } from "../../resources/image"; import { createImage } from '../../resources/image';
type ArticleOptions = { type ArticleOptions = {
cwd: string;
pattern: string;
bundler: Bundler; bundler: Bundler;
}; };
const createArticles = ({ bundler }: ArticleOptions) => { const createArticles = ({ bundler, cwd, pattern }: ArticleOptions) => {
const files = createGlob({ const files = createGlob({
pattern: "content/articles/**/*.md", pattern,
cwd,
create: (path) => { create: (path) => {
const file = createFile({ path }); const file = createFile({ path });
const article = file.pipe(async (raw) => { const article = file.pipe(async (raw) => {
@@ -27,12 +30,12 @@ const createArticles = ({ bundler }: ArticleOptions) => {
}); });
const coverUrl = createImage({ const coverUrl = createImage({
image: resolve(cwd, cover), image: resolve(cwd, cover),
format: "avif", format: 'avif',
bundler, bundler,
}); });
const thumbUrl = createImage({ const thumbUrl = createImage({
image: resolve(cwd, cover), image: resolve(cwd, cover),
format: "avif", format: 'avif',
width: 400, width: 400,
bundler, bundler,
}); });

View File

@@ -1,20 +1,24 @@
import { createGlob } from "../../resources/glob"; import { createGlob } from '../../resources/glob';
import { createFile } from "../../resources/file"; import { createFile } from '../../resources/file';
import grayMatter from "gray-matter"; import grayMatter from 'gray-matter';
import { Bundler } from "../../bundler"; import { Bundler } from '../../bundler';
import { markdownBundleImages } from "../../utils/markdown"; import { markdownBundleImages } from '../../utils/markdown';
import { dirname } from "path"; import { dirname } from 'path';
import { Position } from "../../../types"; import { Position } from '../../../types';
import { Observable } from "../../observable"; import { Observable } from '../../observable';
type PositionOptions = { type PositionOptions = {
cwd: string;
pattern: string;
bundler: Bundler; bundler: Bundler;
}; };
const createPositions = ({ bundler }: PositionOptions) => { const createPositions = ({ cwd, pattern, bundler }: PositionOptions) => {
const files = createGlob<Observable<Position>>({ const files = createGlob<Observable<Position>>({
pattern: "content/resume/positions/**/*.md", pattern,
cwd,
create: (path) => { create: (path) => {
console.log('c', path);
const file = createFile({ path }); const file = createFile({ path });
const position = file.pipe(async (raw) => { const position = file.pipe(async (raw) => {
const { data, content } = grayMatter(raw); const { data, content } = grayMatter(raw);

View File

@@ -1,27 +1,29 @@
import { resolve } from "path"; import { resolve } from 'path';
import { createFile } from "../../resources/file"; import { createFile } from '../../resources/file';
import YAML from "yaml"; import YAML from 'yaml';
import { Bundler } from "../../bundler"; import { Bundler } from '../../bundler';
import { Profile } from "../../../types"; import { Profile } from '../../../types';
import { createImage } from "../../resources/image"; import { createImage } from '../../resources/image';
type ProfileOptions = { type ProfileOptions = {
path: string;
cwd: string;
bundler: Bundler; bundler: Bundler;
}; };
const createProfile = ({ bundler }: ProfileOptions) => { const createProfile = ({ cwd, path, bundler }: ProfileOptions) => {
const file = createFile({ const file = createFile({
path: resolve("content/profile.yml"), path: resolve(cwd, path),
}); });
const profile = file.pipe(async (yaml) => { const profile = file.pipe(async (yaml) => {
const data = YAML.parse(yaml); const data = YAML.parse(yaml);
const imagePath = resolve("content", data.image); const imagePath = resolve('content', data.image);
const result: Profile = { const result: Profile = {
...data, ...data,
imageUrl: createImage({ imageUrl: createImage({
image: imagePath, image: imagePath,
format: "avif", format: 'webp',
bundler, bundler,
}), }),
imagePath, imagePath,

View File

@@ -1,6 +1,6 @@
import express, { Express } from "express"; import express, { Express } from 'express';
import { Bundler } from "../bundler"; import { Bundler } from '../bundler';
import { extname } from "path"; import { extname } from 'path';
const createServer = (bundler: Bundler): Express => { const createServer = (bundler: Bundler): Express => {
const app = express(); const app = express();
@@ -8,35 +8,40 @@ const createServer = (bundler: Bundler): Express => {
let path = req.path; let path = req.path;
let asset = bundler.get(path); let asset = bundler.get(path);
if (!asset) { if (!asset) {
path = path.endsWith("/") ? path + "index.html" : path + "/index.html"; path = path.endsWith('/') ? path + 'index.html' : path + '/index.html';
asset = bundler.get(path); asset = bundler.get(path);
} }
if (asset) { if (asset) {
const ext = extname(path); const ext = extname(path);
asset.data.then((data) => { asset.data
if (ext === ".html") { .then((data) => {
if (ext === '.html') {
const unsubscribe = asset!.subscribe(async () => { const unsubscribe = asset!.subscribe(async () => {
await asset?.data; await asset?.data;
unsubscribe(); unsubscribe();
res.end(`<script>window.location.reload()</script>`); res.end('<script>window.location.reload()</script>');
}); });
res.on("close", unsubscribe); res.on('close', unsubscribe);
res.on("finish", unsubscribe); res.on('finish', unsubscribe);
res.on("error", unsubscribe); res.on('error', unsubscribe);
res.writeHead(200, { res.writeHead(200, {
"content-type": "text/html;charset=utf-8", 'content-type': 'text/html;charset=utf-8',
"Cache-Control": "no-cache, no-store, must-revalidate", 'Cache-Control': 'no-cache, no-store, must-revalidate',
Pragma: "no-cache", Pragma: 'no-cache',
Expires: "0", Expires: '0',
"keep-alive": "timeout=5, max=100", 'keep-alive': 'timeout=5, max=100',
}); });
res.write(data.content.toString().replace("</html>", "")); res.write(data.content.toString().replace('</html>', ''));
} else { } else {
res.send(data.content); res.send(data.content);
} }
})
.catch((err) => {
console.error(err);
res.status(500).send(err.message);
}); });
} else { } else {
res.status(404).send("Not found"); res.status(404).send('Not found');
} }
}); });

View File

@@ -1,21 +1,35 @@
import { program } from "commander"; import { program } from 'commander';
import { build } from "./build"; import { build } from './build';
import { createServer } from "./dev/server"; import { createServer } from './dev/server';
import { dirname, join, resolve } from "path"; import { dirname, join, resolve } from 'path';
import { mkdir, rm, writeFile } from "fs/promises"; import { mkdir, rm, writeFile } from 'fs/promises';
import { existsSync } from "fs"; import { existsSync } from 'fs';
const dev = program.command("dev"); const getConfig = (path: string) => {
dev.action(async () => { const resolved = resolve(path);
const bundler = await build(); const module = require(resolved);
const config = module.default || module;
return {
cwd: dirname(resolved),
config,
};
};
const dev = program.command('dev');
dev.argument('<config>', 'Path to config file');
dev.action(async (configLocation) => {
const { cwd, config } = getConfig(configLocation);
const bundler = await build(cwd, config);
const server = createServer(bundler); const server = createServer(bundler);
server.listen(3000); server.listen(3000);
}); });
const bundle = program.command("build"); const bundle = program.command('build');
bundle.action(async () => { bundle.argument('<config>', 'Path to config file');
const bundler = await build(); bundle.action(async (configLocation) => {
const outputDir = resolve("out"); const { cwd, config } = getConfig(configLocation);
const bundler = await build(cwd, config);
const outputDir = resolve('out');
if (existsSync(outputDir)) { if (existsSync(outputDir)) {
rm(outputDir, { recursive: true }); rm(outputDir, { recursive: true });
} }

View File

@@ -1,15 +1,15 @@
import { Observable } from "./observable"; import { Observable } from './observable';
import { getCollectionItems } from "./utils"; import { getCollectionItems } from './utils';
describe("observable", () => { describe('observable', () => {
it("should be able to create an observable", async () => { it('should be able to create an observable', async () => {
const observable = new Observable(() => Promise.resolve(1)); const observable = new Observable(() => Promise.resolve(1));
expect(observable).toBeDefined(); expect(observable).toBeDefined();
const data = await observable.data; const data = await observable.data;
expect(data).toBe(1); expect(data).toBe(1);
}); });
it("should be able to combine observables", async () => { it('should be able to combine observables', async () => {
const observable1 = new Observable(() => Promise.resolve(1)); const observable1 = new Observable(() => Promise.resolve(1));
const observable2 = new Observable(() => Promise.resolve(2)); const observable2 = new Observable(() => Promise.resolve(2));
const combined = Observable.combine({ observable1, observable2 }); const combined = Observable.combine({ observable1, observable2 });
@@ -18,7 +18,7 @@ describe("observable", () => {
expect(data.observable2).toBe(2); expect(data.observable2).toBe(2);
}); });
it("should be able to update observable", async () => { it('should be able to update observable', async () => {
const observable = new Observable(() => Promise.resolve(1)); const observable = new Observable(() => Promise.resolve(1));
const data = await observable.data; const data = await observable.data;
expect(data).toBe(1); expect(data).toBe(1);
@@ -27,20 +27,20 @@ describe("observable", () => {
expect(data2).toBe(2); expect(data2).toBe(2);
}); });
it("should be able to extract collection items", async () => { it('should be able to extract collection items', async () => {
const observable = new Observable(() => const observable = new Observable(() =>
Promise.resolve([ Promise.resolve([
new Observable(() => Promise.resolve(1)), new Observable(() => Promise.resolve(1)),
new Observable(() => Promise.resolve(2)), new Observable(() => Promise.resolve(2)),
new Observable(() => Promise.resolve(3)), new Observable(() => Promise.resolve(3)),
]) ]),
); );
const flatten = observable.pipe(getCollectionItems); const flatten = observable.pipe(getCollectionItems);
const data = await flatten.data; const data = await flatten.data;
expect(data).toEqual([1, 2, 3]); expect(data).toEqual([1, 2, 3]);
}); });
it("should update observable when subscribed", async () => { it('should update observable when subscribed', async () => {
const observable = new Observable(() => Promise.resolve(1)); const observable = new Observable(() => Promise.resolve(1));
const spy = jest.fn(); const spy = jest.fn();
observable.subscribe(spy); observable.subscribe(spy);
@@ -50,7 +50,7 @@ describe("observable", () => {
expect(spy).toHaveBeenCalledTimes(1); expect(spy).toHaveBeenCalledTimes(1);
}); });
it("should update combined observable when subscribed", async () => { it('should update combined observable when subscribed', async () => {
const observable1 = new Observable(() => Promise.resolve(1)); const observable1 = new Observable(() => Promise.resolve(1));
const observable2 = new Observable(() => Promise.resolve(2)); const observable2 = new Observable(() => Promise.resolve(2));
const combined = Observable.combine({ observable1, observable2 }); const combined = Observable.combine({ observable1, observable2 });

View File

@@ -1,2 +1,2 @@
export { Observable } from "./observable"; export { Observable } from './observable';
export { getCollectionItems } from "./utils"; export { getCollectionItems } from './utils';

View File

@@ -58,7 +58,7 @@ class Observable<T> {
}; };
static combine = <U extends Record<string, Observable<any>>>( static combine = <U extends Record<string, Observable<any>>>(
record: U record: U,
): Observable<ObservableRecord<U>> => { ): Observable<ObservableRecord<U>> => {
const loader = () => const loader = () =>
Object.entries(record).reduce( Object.entries(record).reduce(
@@ -66,7 +66,7 @@ class Observable<T> {
...(await accP), ...(await accP),
[key]: await value.data, [key]: await value.data,
}), }),
{} as any {} as any,
); );
const observable = new Observable<ObservableRecord<U>>(loader); const observable = new Observable<ObservableRecord<U>>(loader);
Object.values(record).forEach((item) => { Object.values(record).forEach((item) => {
@@ -76,6 +76,16 @@ class Observable<T> {
}); });
return observable; return observable;
}; };
static link = <T>(observables: Observable<any>[], generate: () => Promise<T>) => {
const observable = new Observable<T>(generate);
observables.forEach((item) => {
item.subscribe(() => {
observable.recreate();
});
});
return observable;
};
} }
export { Observable }; export { Observable };

View File

@@ -1,4 +1,4 @@
import { Observable } from "./observable"; import { Observable } from './observable';
const getCollectionItems = async <T>(items: Observable<T>[]) => { const getCollectionItems = async <T>(items: Observable<T>[]) => {
return Promise.all(items.map((item) => item.data)); return Promise.all(items.map((item) => item.data));

View File

@@ -1,5 +1,5 @@
import { createFile } from "../file"; import { createFile } from '../file';
import ejs from "ejs"; import ejs from 'ejs';
const createEjs = (path: string) => { const createEjs = (path: string) => {
const file = createFile({ path }); const file = createFile({ path });

View File

@@ -1,16 +1,26 @@
import { readFile } from "fs/promises"; import { readFile } from 'fs/promises';
import { Observable } from "../../observable"; import { Observable } from '../../observable';
import { watch } from "fs"; import { watch } from 'fs';
type FileOptions = { type FileOptions = {
path: string; path: string;
}; };
const createFile = ({ path }: FileOptions) => { const createFile = ({ path }: FileOptions) => {
const file = new Observable(async () => readFile(path, "utf-8")); let watcher: ReturnType<typeof watch> | undefined;
const addWatcher = () => {
watch(path, () => { if (watcher) {
watcher.close();
}
watcher = watch(path, () => {
file.recreate(); file.recreate();
addWatcher();
});
};
const file = new Observable(async () => {
addWatcher();
return readFile(path, 'utf-8');
}); });
return file; return file;

View File

@@ -1,28 +1,25 @@
import fastGlob from "fast-glob"; import fastGlob from 'fast-glob';
import watchGlob from "glob-watcher"; import watchGlob from 'glob-watcher';
import { Observable } from "../../observable"; import { Observable } from '../../observable';
import { resolve } from 'path';
type GlobOptions<T> = { type GlobOptions<T> = {
cwd?: string; cwd: string;
pattern: string; pattern: string;
create?: (path: string) => T; create?: (path: string) => T;
}; };
const defaultCreate = (a: any) => a; const defaultCreate = (a: any) => a;
const createGlob = <T = string>({ const createGlob = <T = string>({ cwd, pattern, create = defaultCreate }: GlobOptions<T>) => {
cwd,
pattern,
create = defaultCreate,
}: GlobOptions<T>) => {
const glob = new Observable(async () => { const glob = new Observable(async () => {
const files = await fastGlob(pattern, { cwd }); const files = await fastGlob(pattern, { cwd });
return files.map(create); return files.map((path) => create(resolve(cwd, path)));
}); });
const watcher = watchGlob(pattern, { cwd }); const watcher = watchGlob(pattern, { cwd });
watcher.on("add", (path) => { watcher.on('add', (path) => {
glob.set((current) => Promise.resolve([...(current || []), create(path)])); glob.set((current) => Promise.resolve([...(current || []), create(resolve(cwd, path))]));
return glob; return glob;
}); });

View File

@@ -1,7 +1,7 @@
import { createHash } from "crypto"; import { createHash } from 'crypto';
import { Asset, Bundler } from "../../bundler"; import { Asset, Bundler } from '../../bundler';
import { Observable } from "../../observable"; import { Observable } from '../../observable';
import sharp, { FormatEnum } from "sharp"; import sharp, { FormatEnum } from 'sharp';
type ImageOptions = { type ImageOptions = {
format: keyof FormatEnum; format: keyof FormatEnum;
@@ -13,8 +13,7 @@ type ImageOptions = {
}; };
const createImage = (options: ImageOptions) => { const createImage = (options: ImageOptions) => {
let path = let path = options.name || createHash('sha256').update(options.image).digest('hex');
options.name || createHash("sha256").update(options.image).digest("hex");
if (options.width) { if (options.width) {
path += `-w${options.width}`; path += `-w${options.width}`;
} }

View File

@@ -1,7 +1,7 @@
import { Asset, Bundler } from "../../bundler"; import { Asset, Bundler } from '../../bundler';
import { Observable } from "../../observable"; import { Observable } from '../../observable';
import { createEjs } from "../ejs"; import { createEjs } from '../ejs';
import { latexToPdf } from "./utils"; import { latexToPdf } from './utils';
type LatexOptions = { type LatexOptions = {
path: string; path: string;
@@ -23,7 +23,11 @@ const createLatex = ({ template, data, path, bundler }: LatexOptions) => {
}; };
return asset; return asset;
}); });
return bundler.register(`${path}.pdf`, pdf); const url = bundler.register(`${path}.pdf`, pdf);
return {
url,
item: pdf,
};
}; };
export { createLatex }; export { createLatex };

View File

@@ -1,5 +1,5 @@
import latex from "node-latex"; import latex from 'node-latex';
import { Readable } from "stream"; import { Readable } from 'stream';
const latexToPdf = (doc: string) => const latexToPdf = (doc: string) =>
new Promise<Buffer>((resolve, reject) => { new Promise<Buffer>((resolve, reject) => {
@@ -8,14 +8,14 @@ const latexToPdf = (doc: string) =>
input.push(doc); input.push(doc);
input.push(null); input.push(null);
const latexStream = latex(input); const latexStream = latex(input);
latexStream.on("data", (chunk) => { latexStream.on('data', (chunk) => {
chunks.push(Buffer.from(chunk)); chunks.push(Buffer.from(chunk));
}); });
latexStream.on("finish", () => { latexStream.on('finish', () => {
const result = Buffer.concat(chunks); const result = Buffer.concat(chunks);
resolve(result); resolve(result);
}); });
latexStream.on("error", (err) => { latexStream.on('error', (err) => {
reject(err); reject(err);
}); });
}); });

View File

@@ -1,10 +1,10 @@
import React, { ComponentType } from "react"; import React, { ComponentType } from 'react';
import { renderToStaticMarkup } from "react-dom/server"; import { renderToStaticMarkup } from 'react-dom/server';
import { HelmetProvider, FilledContext } from "react-helmet-async"; import { HelmetProvider, FilledContext } from 'react-helmet-async';
import { Asset, Bundler } from "../../bundler"; import { Asset, Bundler } from '../../bundler';
import { Observable } from "../../observable"; import { Observable } from '../../observable';
import { ServerStyleSheet } from "styled-components"; import { ServerStyleSheet } from 'styled-components';
import { resolve } from "path"; import { resolve } from 'path';
type PageOptions = { type PageOptions = {
path: string; path: string;
@@ -24,8 +24,8 @@ const createPage = (options: PageOptions) => {
React.createElement( React.createElement(
HelmetProvider, HelmetProvider,
{ context: helmetContext }, { context: helmetContext },
React.createElement(template, props) React.createElement(template, props),
) ),
); );
const bodyHtml = renderToStaticMarkup(body); const bodyHtml = renderToStaticMarkup(body);
const { helmet } = helmetContext; const { helmet } = helmetContext;
@@ -40,7 +40,7 @@ const createPage = (options: PageOptions) => {
helmet.script?.toString(), helmet.script?.toString(),
] ]
.filter(Boolean) .filter(Boolean)
.join(""); .join('');
const html = `<!DOCTYPE html> const html = `<!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
@@ -55,7 +55,7 @@ const createPage = (options: PageOptions) => {
return asset; return asset;
}); });
const path = resolve("/", options.path, "index.html"); const path = resolve('/', options.path, 'index.html');
return options.bundler.register(path, page); return options.bundler.register(path, page);
}; };

View File

@@ -1,27 +1,27 @@
import vm from "vm"; import vm from 'vm';
import React, { ComponentType } from "react"; import React, { ComponentType } from 'react';
import { nodeResolve } from "@rollup/plugin-node-resolve"; import { nodeResolve } from '@rollup/plugin-node-resolve';
import commonjs from "@rollup/plugin-commonjs"; import commonjs from '@rollup/plugin-commonjs';
import json from "@rollup/plugin-json"; import json from '@rollup/plugin-json';
import replace from "@rollup/plugin-replace"; import replace from '@rollup/plugin-replace';
import sucrase from "@rollup/plugin-sucrase"; import sucrase from '@rollup/plugin-sucrase';
import alias from "@rollup/plugin-alias"; import alias from '@rollup/plugin-alias';
import externalGlobals from "rollup-plugin-external-globals"; import externalGlobals from 'rollup-plugin-external-globals';
import { createScript } from "../script"; import { createScript } from '../script';
import orgStyled from "styled-components"; import orgStyled from 'styled-components';
import * as styledExports from "styled-components"; import * as styledExports from 'styled-components';
import ReactHelmetAsync from "react-helmet-async"; import ReactHelmetAsync from 'react-helmet-async';
import { resolve } from "path"; import { resolve } from 'path';
const styled = orgStyled.bind(null); const styled = orgStyled.bind(null);
for (let key of Object.keys(orgStyled)) { for (let key of Object.keys(orgStyled)) {
if (key === "default") { if (key === 'default') {
continue; continue;
} }
(styled as any)[key] = (orgStyled as any)[key]; (styled as any)[key] = (orgStyled as any)[key];
} }
for (let key of Object.keys(styledExports)) { for (let key of Object.keys(styledExports)) {
if (key === "default") { if (key === 'default') {
continue; continue;
} }
(styled as any)[key] = (styledExports as any)[key]; (styled as any)[key] = (styledExports as any)[key];
@@ -30,34 +30,32 @@ for (let key of Object.keys(styledExports)) {
const createReact = <TProps = any>(path: string) => { const createReact = <TProps = any>(path: string) => {
const script = createScript({ const script = createScript({
path, path,
format: "cjs", format: 'cjs',
plugins: [ plugins: [
replace({ replace({
preventAssignment: true, preventAssignment: true,
"process.env.NODE_ENV": JSON.stringify("production"), 'process.env.NODE_ENV': JSON.stringify('production'),
}), }),
alias({ alias({
entries: [ entries: [{ find: '@', replacement: resolve('content/templates/react') }],
{ find: "@", replacement: resolve("content/templates/react") },
],
}), }),
sucrase({ sucrase({
exclude: ["node_modules/**"], exclude: ['node_modules/**'],
transforms: ["jsx", "typescript"], transforms: ['jsx', 'typescript'],
}), }),
nodeResolve({ nodeResolve({
browser: true, browser: true,
preferBuiltins: false, preferBuiltins: false,
extensions: [".js", ".ts", ".tsx"], extensions: ['.js', '.ts', '.tsx'],
}), }),
json(), json(),
commonjs({ commonjs({
include: /node_modules/, include: /node_modules/,
}), }),
externalGlobals({ externalGlobals({
react: "React", react: 'React',
"styled-components": "StyledComponents", 'styled-components': 'StyledComponents',
"react-helmet-async": "ReactHelmetAsync", 'react-helmet-async': 'ReactHelmetAsync',
}), }),
], ],
}); });

View File

@@ -1,5 +1,5 @@
import { Observable } from "../../observable"; import { Observable } from '../../observable';
import { InputPluginOption, ModuleFormat, watch } from "rollup"; import { InputPluginOption, ModuleFormat, watch } from 'rollup';
type ScriptOptions = { type ScriptOptions = {
path: string; path: string;
@@ -22,8 +22,8 @@ const build = (options: ScriptOptions, update: (code: string) => void) =>
}, },
}); });
watcher.on("event", async (event) => { watcher.on('event', async (event) => {
if (event.code === "BUNDLE_END") { if (event.code === 'BUNDLE_END') {
const { output } = await event.result.generate({ const { output } = await event.result.generate({
format: options.format, format: options.format,
}); });
@@ -35,7 +35,7 @@ const build = (options: ScriptOptions, update: (code: string) => void) =>
update(code); update(code);
} }
} }
if (event.code === "ERROR") { if (event.code === 'ERROR') {
reject(event.error); reject(event.error);
} }
}); });
@@ -43,7 +43,7 @@ const build = (options: ScriptOptions, update: (code: string) => void) =>
const createScript = (options: ScriptOptions) => { const createScript = (options: ScriptOptions) => {
const script: Observable<string> = new Observable(() => const script: Observable<string> = new Observable(() =>
build(options, (code) => script.set(() => Promise.resolve(code))) build(options, (code) => script.set(() => Promise.resolve(code))),
); );
return script; return script;

View File

@@ -1,11 +1,11 @@
import { resolve } from "path"; import { resolve } from 'path';
import { decode } from "html-entities"; import { decode } from 'html-entities';
import { marked } from "marked"; import { marked } from 'marked';
import remark from "remark"; import remark from 'remark';
import visit from "unist-util-visit"; import visit from 'unist-util-visit';
import { Bundler } from "../../bundler"; import { Bundler } from '../../bundler';
import { createImage } from "../../resources/image"; import { createImage } from '../../resources/image';
import { renderer } from "./latex"; import { renderer } from './latex';
type MarkdownBundleImagesOptions = { type MarkdownBundleImagesOptions = {
cwd: string; cwd: string;
@@ -13,15 +13,11 @@ type MarkdownBundleImagesOptions = {
bundler: Bundler; bundler: Bundler;
}; };
const markdownBundleImages = async ({ const markdownBundleImages = async ({ bundler, cwd, content }: MarkdownBundleImagesOptions) => {
bundler,
cwd,
content,
}: MarkdownBundleImagesOptions) => {
const result = await remark() const result = await remark()
.use(() => (tree) => { .use(() => (tree) => {
visit(tree, "image", (node) => { visit(tree, 'image', (node) => {
if (!("url" in node)) { if (!('url' in node)) {
return; return;
} }
const url = node.url as string; const url = node.url as string;
@@ -29,7 +25,7 @@ const markdownBundleImages = async ({
const image = createImage({ const image = createImage({
image: path, image: path,
bundler, bundler,
format: "webp", format: 'avif',
}); });
const newUrl = image; const newUrl = image;
node.url = newUrl; node.url = newUrl;
@@ -46,7 +42,7 @@ type MarkdownToLatexOptions = {
const markdownToLatex = ({ root, content }: MarkdownToLatexOptions) => { const markdownToLatex = ({ root, content }: MarkdownToLatexOptions) => {
const render: any = { const render: any = {
...renderer(0), ...renderer(0, root),
}; };
const latex = marked(content, { const latex = marked(content, {
renderer: render, renderer: render,

View File

@@ -1,19 +1,20 @@
import { decode } from "html-entities"; import { decode } from 'html-entities';
import { existsSync } from "fs"; import { existsSync } from 'fs';
import { resolve } from 'path';
const latexTypes = ["", "section", "subsection", "paragraph", "subparagraph"]; const latexTypes = ['', 'section', 'subsection', 'paragraph', 'subparagraph'];
const sanitize = (text?: string) => { const sanitize = (text?: string) => {
if (!text) { if (!text) {
return ""; return '';
} }
return decode(text) return decode(text)
.replace("&", "\\&") .replace('&', '\\&')
.replace("_", "\\_") .replace('_', '\\_')
.replace(/([^\\])\}/g, "$1\\}") .replace(/([^\\])\}/g, '$1\\}')
.replace(/([^\\])\{/g, "$1\\{") .replace(/([^\\])\{/g, '$1\\{')
.replace(/[^\\]\[/g, "\\[") .replace(/[^\\]\[/g, '\\[')
.replace(/#/g, "\\#"); .replace(/#/g, '\\#');
}; };
type Renderer = (depth: number) => { type Renderer = (depth: number) => {
@@ -30,7 +31,7 @@ type Renderer = (depth: number) => {
image?: (link: string) => string; image?: (link: string) => string;
}; };
const renderer = (outerDepth: number) => ({ const renderer = (outerDepth: number, cwd: string) => ({
heading: (text: string, depth: number) => { heading: (text: string, depth: number) => {
return `\\${latexTypes[outerDepth + depth]}{${sanitize(text)}}\n\n`; return `\\${latexTypes[outerDepth + depth]}{${sanitize(text)}}\n\n`;
}, },
@@ -76,13 +77,12 @@ const renderer = (outerDepth: number) => ({
return `\\texttt{${sanitize(code)}}`; return `\\texttt{${sanitize(code)}}`;
}, },
image: (link: string) => { image: (link: string) => {
if (!existsSync(link)) { const path = resolve(cwd, link);
return "Online image not supported"; if (!existsSync(path)) {
return `Online image not supported ${path}`;
} }
return `\\begin{figure}[h!] return `
\\includegraphics[width=0.5\\textwidth]{${link}} \\noindent\\includegraphics[width=\\linewidth]{${path}}
\\centering
\\end{figure}
`; `;
}, },
}); });

View File

@@ -1,14 +1,10 @@
import { Observable } from "../../observable"; import { Observable } from '../../observable';
const forEach = async <T extends Observable<any[]>>( const forEach = async <T extends Observable<any[]>>(
observable: T, observable: T,
fn: ( fn: (
value: T extends Observable<infer U> value: T extends Observable<infer U> ? (U extends Array<infer A> ? A : never) : never,
? U extends Array<infer A> ) => Promise<void>,
? A
: never
: never
) => Promise<void>
) => { ) => {
const knownValues = new Set(); const knownValues = new Set();
const update = async () => { const update = async () => {

31
content/config.ts Normal file
View File

@@ -0,0 +1,31 @@
import { Config } from '../types/config';
const config: Config = {
profile: {
path: 'profile.yml',
},
frontpage: {
react: {
template: 'templates/react/pages/frontpage/index.tsx',
},
},
resume: {
latex: {
template: 'templates/latex/resume.tex',
},
},
articles: {
pattern: 'articles/**/*.md',
react: {
template: 'templates/react/pages/article/index.tsx',
},
latex: {
template: 'templates/latex/article.tex',
},
},
positions: {
pattern: 'resume/positions/**/*.md',
},
};
export default config;

View File

@@ -5,4 +5,4 @@ from: 2022
to: Present to: Present
--- ---
Hello world // TODO

View File

@@ -1,9 +1,28 @@
\documentclass{article} \documentclass{article}
\usepackage[top=2cm, bottom=2cm, left=2cm, right=2cm]{geometry}
\usepackage{graphicx} \usepackage{graphicx}
\usepackage{hyperref} \usepackage{hyperref}
\usepackage{multicol}
\usepackage{fancyhdr}
\pagestyle{fancy}
\fancyhf{}
\rhead{<%-profile.name%> \today}
\lhead{<%-article.title%>}
\rfoot{Page \thepage}
\title{<%-article.title%>} \title{<%-article.title%>}
\begin{document} \begin{document}
\maketitle
\includegraphics[width=0.5\textwidth]{<%-article.cover%>} \begin{multicols}{2}
\noindent\begin{minipage}{\linewidth}
\Huge{<%-article.title%>}
\newline
\large{By <%-profile.name%>}
\vspace{0.5cm}\\
\includegraphics[width=\linewidth]{<%-article.cover%>}
\vspace{1.5cm}
\end{minipage}
<%-article.body%> <%-article.body%>
\end{multicols}
\end{document} \end{document}

View File

@@ -1,9 +1,9 @@
import React, { useMemo } from "react"; import React, { useMemo } from 'react';
import styled from "styled-components"; import styled from 'styled-components';
import ArticlePreview from "../preview"; import ArticlePreview from '../preview';
import { JumboArticlePreview } from "../preview/jumbo"; import { JumboArticlePreview } from '../preview/jumbo';
import { MiniArticlePreview } from "../preview/mini"; import { MiniArticlePreview } from '../preview/mini';
import { Article } from "types"; import { Article } from 'types';
type Props = { type Props = {
articles: Article[]; articles: Article[];
@@ -47,7 +47,7 @@ const ArticleGrid: React.FC<Props> = ({ articles }) => {
// new Date(b.published).getTime() - // new Date(b.published).getTime() -
// new Date(a.published).getTime() // new Date(a.published).getTime()
// ), // ),
[articles] [articles],
); );
const featured1 = useMemo(() => sorted.slice(0, 1)[0], [sorted]); const featured1 = useMemo(() => sorted.slice(0, 1)[0], [sorted]);

View File

@@ -1,9 +1,9 @@
import React, { useMemo } from "react"; import React, { useMemo } from 'react';
import styled from "styled-components"; import styled from 'styled-components';
import { Title1 } from "@/typography"; import { Title1 } from '@/typography';
import { createTheme } from "@/theme/create"; import { createTheme } from '@/theme/create';
import { ThemeProvider } from "@/theme/provider"; import { ThemeProvider } from '@/theme/provider';
import { Article } from "types"; import { Article } from 'types';
type Props = { type Props = {
article: Article; article: Article;
@@ -28,7 +28,7 @@ const Wrapper = styled.a`
const Title = styled(Title1)` const Title = styled(Title1)`
background: ${({ theme }) => theme.colors.primary}; background: ${({ theme }) => theme.colors.primary};
line-height: 40px; line-height: 40px;
font-family: "Black Ops One", sans-serif; font-family: 'Black Ops One', sans-serif;
font-size: 25px; font-size: 25px;
padding: 0 5px; padding: 0 5px;
margin: 5px 0; margin: 5px 0;
@@ -48,7 +48,7 @@ const AsideWrapper = styled.aside<{
background: ${({ theme }) => theme.colors.primary}; background: ${({ theme }) => theme.colors.primary};
background-size: cover; background-size: cover;
background-position: center; background-position: center;
${({ image }) => (image ? `background-image: url(${image});` : "")} ${({ image }) => (image ? `background-image: url(${image});` : '')}
flex: 1; flex: 1;
top: 0; top: 0;
bottom: 0; bottom: 0;
@@ -63,14 +63,14 @@ const ArticlePreview: React.FC<Props> = ({ article }) => {
createTheme({ createTheme({
baseColor: article.color, baseColor: article.color,
}), }),
[article.color] [article.color],
); );
return ( return (
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<Wrapper href={`/articles/${article.slug}`}> <Wrapper href={`/articles/${article.slug}`}>
<AsideWrapper image={article.thumbUrl} /> <AsideWrapper image={article.thumbUrl} />
<MetaWrapper> <MetaWrapper>
{article.title.split(" ").map((word, index) => ( {article.title.split(' ').map((word, index) => (
<Title key={index}>{word}</Title> <Title key={index}>{word}</Title>
))} ))}
</MetaWrapper> </MetaWrapper>

View File

@@ -1,7 +1,7 @@
import React from "react"; import React from 'react';
import styled from "styled-components"; import styled from 'styled-components';
import { Title1, Body1 } from "@/typography"; import { Title1, Body1 } from '@/typography';
import { Article } from "types"; import { Article } from 'types';
type Props = { type Props = {
article: Article; article: Article;
@@ -24,7 +24,7 @@ const Wrapper = styled.a`
const Title = styled(Title1)` const Title = styled(Title1)`
line-height: 40px; line-height: 40px;
font-family: "Black Ops One", sans-serif; font-family: 'Black Ops One', sans-serif;
font-size: 25px; font-size: 25px;
padding: 0 5px; padding: 0 5px;
margin: 5px 0; margin: 5px 0;
@@ -55,7 +55,7 @@ const AsideWrapper = styled.aside<{
background: ${({ theme }) => theme.colors.primary}; background: ${({ theme }) => theme.colors.primary};
background-size: cover; background-size: cover;
background-position: center; background-position: center;
${({ image }) => (image ? `background-image: url(${image});` : "")} ${({ image }) => (image ? `background-image: url(${image});` : '')}
flex: 1; flex: 1;
top: 0; top: 0;
bottom: 0; bottom: 0;

View File

@@ -1,9 +1,9 @@
import React, { useMemo } from "react"; import React, { useMemo } from 'react';
import styled from "styled-components"; import styled from 'styled-components';
import { Title1 } from "@/typography"; import { Title1 } from '@/typography';
import { createTheme } from "@/theme/create"; import { createTheme } from '@/theme/create';
import { ThemeProvider } from "@/theme/provider"; import { ThemeProvider } from '@/theme/provider';
import { Article } from "types"; import { Article } from 'types';
type Props = { type Props = {
article: Article; article: Article;
@@ -26,7 +26,7 @@ const Title = styled(Title1)`
line-height: 20px; line-height: 20px;
font-size: 20px; font-size: 20px;
padding: 5px 5px; padding: 5px 5px;
font-family: "Black Ops One", sans-serif; font-family: 'Black Ops One', sans-serif;
margin: 5px 0; margin: 5px 0;
background: ${({ theme }) => theme.colors.background}; background: ${({ theme }) => theme.colors.background};
`; `;
@@ -46,7 +46,7 @@ const AsideWrapper = styled.aside<{
background: ${({ theme }) => theme.colors.primary}; background: ${({ theme }) => theme.colors.primary};
background-size: cover; background-size: cover;
background-position: center; background-position: center;
${({ image }) => (image ? `background-image: url(${image});` : "")} ${({ image }) => (image ? `background-image: url(${image});` : '')}
position: absolute; position: absolute;
top: 0; top: 0;
bottom: 0; bottom: 0;
@@ -61,14 +61,14 @@ const MiniArticlePreview: React.FC<Props> = ({ article }) => {
createTheme({ createTheme({
baseColor: article.color, baseColor: article.color,
}), }),
[article.color] [article.color],
); );
return ( return (
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<Wrapper href={`/articles/${article.slug}`}> <Wrapper href={`/articles/${article.slug}`}>
<AsideWrapper image={article.thumbUrl} /> <AsideWrapper image={article.thumbUrl} />
<MetaWrapper> <MetaWrapper>
{article.title.split(" ").map((word, index) => ( {article.title.split(' ').map((word, index) => (
<Title key={index}>{word}</Title> <Title key={index}>{word}</Title>
))} ))}
</MetaWrapper> </MetaWrapper>

View File

@@ -1,4 +1,4 @@
import { FC, ReactNode } from "react" import { FC, ReactNode } from 'react';
type HtmlProps = { type HtmlProps = {
body: ReactNode; body: ReactNode;
@@ -18,7 +18,10 @@ const Html: FC<HtmlProps> = ({ body, head, scripts }) => {
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" /> <link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link href="https://fonts.googleapis.com/css2?family=Archivo+Black&family=Black+Ops+One&family=Merriweather:wght@400;700&display=swap" rel="stylesheet" /> <link
href="https://fonts.googleapis.com/css2?family=Archivo+Black&family=Black+Ops+One&family=Merriweather:wght@400;700&display=swap"
rel="stylesheet"
/>
</head> </head>
<body> <body>
<div id="root">{body}</div> <div id="root">{body}</div>

View File

@@ -1,7 +1,7 @@
import React, { ReactNode, useMemo } from "react"; import React, { ReactNode, useMemo } from 'react';
import styled from "styled-components"; import styled from 'styled-components';
import { createTheme } from "@/theme/create"; import { createTheme } from '@/theme/create';
import { ThemeProvider } from "@/theme/provider"; import { ThemeProvider } from '@/theme/provider';
const Wrapper = styled.div` const Wrapper = styled.div`
background: ${({ theme }) => theme.colors.background}; background: ${({ theme }) => theme.colors.background};
@@ -25,7 +25,7 @@ const BackgroundWrapper = styled.div<{
background-size: cover; background-size: cover;
background-position: center; background-position: center;
opacity: 0.2; opacity: 0.2;
${({ image }) => (image ? `background-image: url(${image});` : "")} ${({ image }) => (image ? `background-image: url(${image});` : '')}
`; `;
const Content = styled.div` const Content = styled.div`
@@ -50,7 +50,7 @@ const Sheet: React.FC<Props> = ({ color, background, children }) => {
createTheme({ createTheme({
baseColor: color, baseColor: color,
}), }),
[color] [color],
); );
return ( return (
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>

View File

@@ -1,9 +1,9 @@
import styled, { createGlobalStyle } from "styled-components"; import styled, { createGlobalStyle } from 'styled-components';
import ReactMarkdown from "react-markdown"; import ReactMarkdown from 'react-markdown';
import { Jumbo } from "./typography"; import { Jumbo } from '../../typography';
import { createTheme, ThemeProvider } from "./theme"; import { createTheme, ThemeProvider } from '../../theme';
import { Helmet } from "react-helmet-async"; import { Helmet } from 'react-helmet-async';
import { Page } from "types"; import { Page } from 'types';
const GlobalStyle = createGlobalStyle` const GlobalStyle = createGlobalStyle`
* { box-sizing: border-box; } * { box-sizing: border-box; }
@@ -36,7 +36,7 @@ const ArticleTitleWord = styled(Jumbo)`
padding: 0 15px; padding: 0 15px;
text-transform: uppercase; text-transform: uppercase;
margin: 10px; margin: 10px;
font-family: "Black Ops One", sans-serif; font-family: 'Black Ops One', sans-serif;
background: ${({ theme }) => theme.colors.primary}; background: ${({ theme }) => theme.colors.primary};
color: ${({ theme }) => theme.colors.foreground}; color: ${({ theme }) => theme.colors.foreground};
@media only screen and (max-width: 900px) { @media only screen and (max-width: 900px) {
@@ -57,7 +57,7 @@ const Wrapper = styled.div`
const ArticleWrapper = styled.article` const ArticleWrapper = styled.article`
font-size: 1.1rem; font-size: 1.1rem;
font-family: "Merriweather", serif; font-family: 'Merriweather', serif;
> p, > p,
ul, ul,
@@ -82,7 +82,7 @@ const ArticleWrapper = styled.article`
} }
> p:first-of-type::first-letter { > p:first-of-type::first-letter {
font-family: "Black Ops One", sans-serif; font-family: 'Black Ops One', sans-serif;
border: solid 5px ${({ theme }) => theme.colors.foreground}; border: solid 5px ${({ theme }) => theme.colors.foreground};
margin: 0 1rem 0 0; margin: 0 1rem 0 0;
font-size: 6rem; font-size: 6rem;
@@ -118,7 +118,7 @@ const ArticleWrapper = styled.article`
padding-right: 40px; padding-right: 40px;
shape-outside: padding-box; shape-outside: padding-box;
position: relative; position: relative;
font-family: "Black Ops One", sans-serif; font-family: 'Black Ops One', sans-serif;
text-transform: uppercase; text-transform: uppercase;
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
@@ -132,7 +132,7 @@ const ArticleWrapper = styled.article`
&:after { &:after {
position: absolute; position: absolute;
content: ""; content: '';
right: 20px; right: 20px;
top: 0; top: 0;
bottom: 0; bottom: 0;
@@ -162,14 +162,14 @@ const ArticleWrapper = styled.article`
&:before { &:before {
color: ${({ theme }) => theme.colors.primary}; color: ${({ theme }) => theme.colors.primary};
content: "\\00BB"; content: '\\00BB';
float: left; float: left;
font-size: 6rem; font-size: 6rem;
} }
&:after { &:after {
position: absolute; position: absolute;
content: ""; content: '';
right: 20px; right: 20px;
top: 0; top: 0;
bottom: 0; bottom: 0;
@@ -222,14 +222,14 @@ const Download = styled.a`
text-align: center; text-align: center;
padding: 1rem; padding: 1rem;
font-size: 1rem; font-size: 1rem;
font-family: "Black Ops One", sans-serif; font-family: 'Black Ops One', sans-serif;
text-transform: uppercase; text-transform: uppercase;
text-decoration: none; text-decoration: none;
`; `;
const Author = styled.a` const Author = styled.a`
text-transform: uppercase; text-transform: uppercase;
font-family: "Black Ops One", sans-serif; font-family: 'Black Ops One', sans-serif;
font-size: 2rem; font-size: 2rem;
margin: 1rem; margin: 1rem;
display: inline-block; display: inline-block;
@@ -238,7 +238,7 @@ const Author = styled.a`
color: ${({ theme }) => theme.colors.foreground}; color: ${({ theme }) => theme.colors.foreground};
&:after { &:after {
content: ""; content: '';
border-bottom: solid 15px ${({ theme }) => theme.colors.primary}; border-bottom: solid 15px ${({ theme }) => theme.colors.primary};
display: block; display: block;
width: 100%; width: 100%;
@@ -249,17 +249,13 @@ const Author = styled.a`
} }
`; `;
const ArticlePage: Page<"article"> = ({ article, profile, pdfUrl }) => { const ArticlePage: Page<'article'> = ({ article, profile, pdfUrl }) => {
return ( return (
<ThemeProvider theme={createTheme({ baseColor: article.color })}> <ThemeProvider theme={createTheme({ baseColor: article.color })}>
<Helmet> <Helmet>
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link <link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
rel="preconnect"
href="https://fonts.gstatic.com"
crossOrigin="anonymous"
/>
<link <link
href="https://fonts.googleapis.com/css2?family=Archivo+Black&family=Black+Ops+One&family=Merriweather:wght@400;700&display=swap" href="https://fonts.googleapis.com/css2?family=Archivo+Black&family=Black+Ops+One&family=Merriweather:wght@400;700&display=swap"
rel="stylesheet" rel="stylesheet"
@@ -269,7 +265,7 @@ const ArticlePage: Page<"article"> = ({ article, profile, pdfUrl }) => {
<Wrapper> <Wrapper>
<Content> <Content>
<Title> <Title>
{article.title.split(" ").map((word, index) => ( {article.title.split(' ').map((word, index) => (
<ArticleTitleWord key={index}>{word}</ArticleTitleWord> <ArticleTitleWord key={index}>{word}</ArticleTitleWord>
))} ))}
<Author href="/">by {profile.name}</Author> <Author href="/">by {profile.name}</Author>

View File

@@ -1,12 +1,12 @@
import styled, { createGlobalStyle } from "styled-components"; import styled, { createGlobalStyle } from 'styled-components';
import { ArticleGrid } from "@/components/article/grid"; import { ArticleGrid } from '@/components/article/grid';
import { Jumbo } from "@/typography"; import { Jumbo } from '@/typography';
import { useMemo } from "react"; import { useMemo } from 'react';
import { Sheet } from "./components/sheet"; import { Sheet } from '../../components/sheet';
import { ThemeProvider, createTheme } from "./theme"; import { ThemeProvider, createTheme } from '@/theme';
import chroma from "chroma-js"; import chroma from 'chroma-js';
import { Helmet } from "react-helmet-async"; import { Helmet } from 'react-helmet-async';
import { Page } from "../../../types"; import { Page } from 'types';
const GlobalStyle = createGlobalStyle` const GlobalStyle = createGlobalStyle`
* { box-sizing: border-box; } * { box-sizing: border-box; }
@@ -32,7 +32,7 @@ const Download = styled.a`
padding: 0 15px; padding: 0 15px;
text-transform: uppercase; text-transform: uppercase;
margin: 10px; margin: 10px;
font-family: "Black Ops One", sans-serif; font-family: 'Black Ops One', sans-serif;
@media only screen and (max-width: 700px) { @media only screen and (max-width: 700px) {
margin: 5px; margin: 5px;
font-size: 3rem; font-size: 3rem;
@@ -49,7 +49,7 @@ const Title = styled(Jumbo)`
padding: 0 15px; padding: 0 15px;
text-transform: uppercase; text-transform: uppercase;
margin: 10px; margin: 10px;
font-family: "Black Ops One", sans-serif; font-family: 'Black Ops One', sans-serif;
@media only screen and (max-width: 700px) { @media only screen and (max-width: 700px) {
margin: 5px; margin: 5px;
font-size: 3rem; font-size: 3rem;
@@ -71,7 +71,7 @@ const Arrow = styled.div`
border-radius: 50%; border-radius: 50%;
width: 80px; width: 80px;
height: 80px; height: 80px;
content: "↓"; content: '↓';
font-size: 50px; font-size: 50px;
@media only screen and (max-width: 700px) { @media only screen and (max-width: 700px) {
width: 40px; width: 40px;
@@ -99,13 +99,13 @@ const ImageBg = styled.picture`
} }
`; `;
const FrontPage: Page<"frontpage"> = ({ articles, profile }) => { const FrontPage: Page<'frontpage'> = ({ articles, profile }) => {
const theme = useMemo( const theme = useMemo(
() => () =>
createTheme({ createTheme({
baseColor: chroma.random().brighten(1).hex(), baseColor: chroma.random().brighten(1).hex(),
}), }),
[] [],
); );
return ( return (
@@ -113,11 +113,7 @@ const FrontPage: Page<"frontpage"> = ({ articles, profile }) => {
<Helmet> <Helmet>
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link <link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
rel="preconnect"
href="https://fonts.gstatic.com"
crossOrigin="anonymous"
/>
<link <link
href="https://fonts.googleapis.com/css2?family=Archivo+Black&family=Black+Ops+One&family=Merriweather:wght@400;700&display=swap" href="https://fonts.googleapis.com/css2?family=Archivo+Black&family=Black+Ops+One&family=Merriweather:wght@400;700&display=swap"
rel="stylesheet" rel="stylesheet"
@@ -130,12 +126,12 @@ const FrontPage: Page<"frontpage"> = ({ articles, profile }) => {
</ImageBg> </ImageBg>
<Arrow /> <Arrow />
<Hero> <Hero>
{"Hi, I'm Morten".split(" ").map((char, index) => ( {"Hi, I'm Morten".split(' ').map((char, index) => (
<Title key={index}>{char}</Title> <Title key={index}>{char}</Title>
))} ))}
</Hero> </Hero>
<Hero> <Hero>
{"And I make software".split(" ").map((char, index) => ( {'And I make software'.split(' ').map((char, index) => (
<Title key={index}>{char}</Title> <Title key={index}>{char}</Title>
))} ))}
</Hero> </Hero>
@@ -147,7 +143,7 @@ const FrontPage: Page<"frontpage"> = ({ articles, profile }) => {
</Sheet> </Sheet>
<Sheet color="#ef23e2"> <Sheet color="#ef23e2">
<Hero> <Hero>
{"Table of Content".split(" ").map((char, index) => ( {'Table of Content'.split(' ').map((char, index) => (
<Title key={index}>{char}</Title> <Title key={index}>{char}</Title>
))} ))}
</Hero> </Hero>

View File

@@ -11,13 +11,9 @@ type CreateOptions = {
const isBright = (color: chroma.Color) => color.luminance() > 0.4; const isBright = (color: chroma.Color) => color.luminance() > 0.4;
const createTheme = (options: CreateOptions = {}) => { const createTheme = (options: CreateOptions = {}) => {
const baseColor = options.baseColor const baseColor = options.baseColor ? chroma(options.baseColor) : chroma.random();
? chroma(options.baseColor)
: chroma.random();
const text = isBright(baseColor) ? BLACK : WHITE; const text = isBright(baseColor) ? BLACK : WHITE;
const bg = isBright(baseColor) const bg = isBright(baseColor) ? baseColor.luminance(0.9) : baseColor.luminance(0.01);
? baseColor.luminance(0.9)
: baseColor.luminance(0.01);
const theme: Theme = { const theme: Theme = {
typography: { typography: {
Jumbo: { Jumbo: {
@@ -57,4 +53,3 @@ const createTheme = (options: CreateOptions = {}) => {
}; };
export { createTheme }; export { createTheme };

View File

@@ -3,4 +3,3 @@ import { Theme } from './theme';
declare module 'styled-components' { declare module 'styled-components' {
export interface DefaultTheme extends Theme {} export interface DefaultTheme extends Theme {}
} }

View File

@@ -1,49 +1,43 @@
import styled from "styled-components"; import styled from 'styled-components';
import { Theme, Typography } from "../theme"; import { Theme, Typography } from '../theme';
interface TextProps { interface TextProps {
color?: keyof Theme["colors"]; color?: keyof Theme['colors'];
bold?: boolean; bold?: boolean;
theme: Theme; theme: Theme;
} }
const BaseText = styled.span<TextProps>` const BaseText = styled.span<TextProps>`
${({ theme }) => ${({ theme }) => (theme.font.family ? `font-family: ${theme.font.family};` : '')}
theme.font.family ? `font-family: ${theme.font.family};` : ""} color: ${({ color, theme }) => (color ? theme.colors[color] : theme.colors.foreground)};
color: ${({ color, theme }) => font-weight: ${({ bold }) => (bold ? 'bold' : 'normal')};
color ? theme.colors[color] : theme.colors.foreground};
font-weight: ${({ bold }) => (bold ? "bold" : "normal")};
font-size: ${({ theme }) => theme.font.baseSize}px; font-size: ${({ theme }) => theme.font.baseSize}px;
`; `;
const get = (name: keyof Theme["typography"], theme: Theme): Typography => { const get = (name: keyof Theme['typography'], theme: Theme): Typography => {
const typography = theme.typography[name]; const typography = theme.typography[name];
return typography; return typography;
}; };
const createTypography = (name: keyof Theme["typography"]) => { const createTypography = (name: keyof Theme['typography']) => {
const Component = styled(BaseText)<TextProps>` const Component = styled(BaseText)<TextProps>`
font-size: ${({ theme }) => font-size: ${({ theme }) => theme.font.baseSize * (get(name, theme).size || 1)}px;
theme.font.baseSize * (get(name, theme).size || 1)}px;
font-weight: ${({ bold, theme }) => font-weight: ${({ bold, theme }) =>
typeof bold !== "undefined" typeof bold !== 'undefined' ? 'bold' : get(name, theme).weight || 'normal'};
? "bold" ${({ theme }) => (get(name, theme).upperCase ? 'text-transform: uppercase;' : '')}
: get(name, theme).weight || "normal"};
${({ theme }) =>
get(name, theme).upperCase ? "text-transform: uppercase;" : ""}
`; `;
return Component; return Component;
}; };
const Jumbo = createTypography("Jumbo"); const Jumbo = createTypography('Jumbo');
const Title2 = createTypography("Title2"); const Title2 = createTypography('Title2');
const Title1 = createTypography("Title1"); const Title1 = createTypography('Title1');
const Body1 = createTypography("Body1"); const Body1 = createTypography('Body1');
const Overline = createTypography("Overline"); const Overline = createTypography('Overline');
const Caption = createTypography("Caption"); const Caption = createTypography('Caption');
const Link = createTypography("Link"); const Link = createTypography('Link');
const types: { [key in keyof Theme["typography"]]: typeof BaseText } = { const types: { [key in keyof Theme['typography']]: typeof BaseText } = {
Jumbo, Jumbo,
Title2, Title2,
Title1, Title1,

View File

@@ -43,6 +43,7 @@
"yaml": "^2.2.1" "yaml": "^2.2.1"
}, },
"devDependencies": { "devDependencies": {
"@react-native-community/eslint-config": "^3.2.0",
"@types/chroma-js": "^2.4.0", "@types/chroma-js": "^2.4.0",
"@types/ejs": "^3.1.2", "@types/ejs": "^3.1.2",
"@types/express": "^4.17.17", "@types/express": "^4.17.17",
@@ -54,7 +55,9 @@
"@types/react-dom": "^18.0.11", "@types/react-dom": "^18.0.11",
"@types/sharp": "^0.31.1", "@types/sharp": "^0.31.1",
"@types/styled-components": "^5.1.26", "@types/styled-components": "^5.1.26",
"eslint": "^8.36.0",
"jest": "^29.5.0", "jest": "^29.5.0",
"prettier": "^2.8.7",
"ts-jest": "^29.0.5", "ts-jest": "^29.0.5",
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"typescript": "^5.0.2" "typescript": "^5.0.2"

1241
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

29
types/config.ts Normal file
View File

@@ -0,0 +1,29 @@
interface Config {
profile: {
path: string;
};
frontpage: {
react: {
template: string;
};
};
articles: {
pattern: string;
react: {
template: string;
};
latex: {
template: string;
};
};
resume: {
latex: {
template: string;
};
};
positions: {
pattern: string;
};
}
export { Config };