feat: added latex generation
11
.babelrc
@@ -1,4 +1,11 @@
|
||||
{
|
||||
"presets": ["next/babel"],
|
||||
"plugins": [["styled-components", { "ssr": true }]]
|
||||
"presets": [
|
||||
["next/babel", {
|
||||
}]
|
||||
],
|
||||
"plugins": [
|
||||
"babel-plugin-transform-typescript-metadata",
|
||||
["@babel/plugin-proposal-decorators", { "legacy": true }],
|
||||
["styled-components", { "ssr": true }]
|
||||
]
|
||||
}
|
||||
|
||||
35
config/plugins/withLatex/index.js
Normal file
@@ -0,0 +1,35 @@
|
||||
const withLatex = (nextConfig = {}) => {
|
||||
return Object.assign({}, nextConfig, {
|
||||
webpack(config, options) {
|
||||
const {isServer, dev} = options;
|
||||
let outputPath = '';
|
||||
if (isServer && dev) {
|
||||
outputPath = "../";
|
||||
} else if (isServer) {
|
||||
outputPath = "../../";
|
||||
}
|
||||
config.module.rules.push({
|
||||
test: /\.tex.yml$/,
|
||||
use: [{
|
||||
loader: 'file-loader',
|
||||
options: {
|
||||
|
||||
publicPath: `${nextConfig.assetPrefix || nextConfig.basePath || ''}/_next/static/images/`,
|
||||
outputPath: `${outputPath}static/images/`,
|
||||
name: "[name]-[hash].pdf",
|
||||
esModule: nextConfig.esModule || false,
|
||||
},
|
||||
}, {
|
||||
loader: require.resolve('./webpack.js'),
|
||||
}],
|
||||
});
|
||||
if (typeof nextConfig.webpack === "function") {
|
||||
return nextConfig.webpack(config, options);
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = withLatex;
|
||||
31
config/plugins/withLatex/webpack.js
Normal file
@@ -0,0 +1,31 @@
|
||||
require('reflect-metadata');
|
||||
require('@babel/register')({
|
||||
extensions: [".es6", ".es", ".jsx", ".js", ".mjs", ".ts"],
|
||||
});
|
||||
const latex = require("node-latex")
|
||||
var Readable = require('stream').Readable
|
||||
const { generateLatex } = require('../../../src/latex');
|
||||
|
||||
module.exports = function (source) {
|
||||
var callback = this.async();
|
||||
const location = this.resourcePath;
|
||||
generateLatex(source, location)
|
||||
.then((result) => {
|
||||
const chunks = [];
|
||||
const input = new Readable();
|
||||
input.push(result);
|
||||
input.push(null);
|
||||
const latexStream = latex(input);
|
||||
latexStream.on('data', (chunk) => {
|
||||
chunks.push(Buffer.from(chunk));
|
||||
})
|
||||
latexStream.on('finish', () => {
|
||||
const result = Buffer.concat(chunks);
|
||||
callback(null, result);
|
||||
})
|
||||
latexStream.on('error', (err) => {
|
||||
callback(err);
|
||||
})
|
||||
})
|
||||
.catch(callback);
|
||||
}
|
||||
3
content/articles/hiring/a4.tex.yml
Normal file
@@ -0,0 +1,3 @@
|
||||
data:
|
||||
structure: ./index.yml
|
||||
generator: article
|
||||
|
Before Width: | Height: | Size: 168 KiB After Width: | Height: | Size: 168 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 68 KiB |
@@ -1,5 +1,5 @@
|
||||
title: How to hire engineers, by an engineer
|
||||
cover: cover.png
|
||||
published: 2021-03-15
|
||||
published: 2022-03-16
|
||||
parts:
|
||||
- main.md
|
||||
3
content/articles/hyperconnect/a4.tex.yml
Normal file
@@ -0,0 +1,3 @@
|
||||
data:
|
||||
structure: ./index.yml
|
||||
generator: article
|
||||
3
content/articles/my-home-runs-redux/a4.tex.yml
Normal file
@@ -0,0 +1,3 @@
|
||||
data:
|
||||
structure: ./index.yml
|
||||
generator: article
|
||||
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
BIN
content/articles/my-home-runs-redux/graph.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
@@ -1,5 +1,5 @@
|
||||
title: My home runs Redux
|
||||
cover: cover.png
|
||||
published: 2021-03-15
|
||||
published: 2022-03-15
|
||||
parts:
|
||||
- main.md
|
||||
@@ -56,7 +56,7 @@ Now comes the part I have feared, where I need to draw a diagram.
|
||||
...sorry
|
||||
|
||||
|
||||

|
||||

|
||||
|
||||
So this shows our final setup.
|
||||
|
||||
1
content/profile/a4.tex.yml
Normal file
@@ -0,0 +1 @@
|
||||
generator: resume
|
||||
|
Before Width: | Height: | Size: 559 B After Width: | Height: | Size: 559 B |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 553 B After Width: | Height: | Size: 553 B |
|
Before Width: | Height: | Size: 558 KiB After Width: | Height: | Size: 558 KiB |
@@ -1,6 +1,7 @@
|
||||
const withPlugins = require("next-compose-plugins");
|
||||
|
||||
const withImages = require("./config/plugins/withImages.js");
|
||||
const withLatex = require('./config/plugins/withLatex');
|
||||
const withBundleAnalyzer = require("@next/bundle-analyzer")({
|
||||
enabled: process.env.ANALYZE === "true",
|
||||
});
|
||||
@@ -13,6 +14,7 @@ const nextConfig = {
|
||||
};
|
||||
|
||||
module.exports = withPlugins([
|
||||
withLatex,
|
||||
[withImages,{
|
||||
esModule: true, // using ES modules is beneficial in the case of module concatenation and tree shaking.
|
||||
inlineImageLimit: 0, // disable image inlining to data:base64
|
||||
|
||||
22
package.json
@@ -11,16 +11,25 @@
|
||||
"@fontsource/pacifico": "^4.5.3",
|
||||
"@react-three/drei": "^8.18.6",
|
||||
"@react-three/fiber": "^7.0.26",
|
||||
"date-fns": "^2.28.0",
|
||||
"next": "^12.1.0",
|
||||
"node-latex": "^3.1.0",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-markdown": "^8.0.1",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rehype-img-size": "^1.0.1",
|
||||
"remark-html": "^15.0.1",
|
||||
"styled-components": "^5.3.3",
|
||||
"three": "^0.138.3"
|
||||
"three": "^0.138.3",
|
||||
"typedi": "^0.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.17.8",
|
||||
"@babel/plugin-proposal-class-properties": "^7.16.7",
|
||||
"@babel/plugin-proposal-decorators": "^7.17.8",
|
||||
"@babel/plugin-transform-modules-commonjs": "^7.17.7",
|
||||
"@babel/register": "^7.17.7",
|
||||
"@next/bundle-analyzer": "^12.1.0",
|
||||
"@types/fs-extra": "^9.0.13",
|
||||
"@types/marked": "^4.0.3",
|
||||
@@ -29,17 +38,24 @@
|
||||
"@types/three": "^0.138.0",
|
||||
"@types/yaml": "^1.9.7",
|
||||
"babel-plugin-styled-components": "^2.0.6",
|
||||
"babel-plugin-transform-typescript-metadata": "^0.3.2",
|
||||
"babel-register-esm": "^1.2.1",
|
||||
"file-loader": "^6.2.0",
|
||||
"framer-motion": "^6.2.8",
|
||||
"fs-extra": "^10.0.1",
|
||||
"html-entities": "^2.3.3",
|
||||
"marked": "^4.0.12",
|
||||
"next-compose-plugins": "^2.2.1",
|
||||
"next-images": "^1.8.4",
|
||||
"next-mdx-remote": "^4.0.0",
|
||||
"remark": "^14.0.2",
|
||||
"remark-behead": "^3.0.0",
|
||||
"os": "^0.1.2",
|
||||
"reading-time": "^1.5.0",
|
||||
"remark": "^13.0.0",
|
||||
"remark-behead": "^2.3.3",
|
||||
"remark-slug": "^7.0.1",
|
||||
"ts-node": "^10.7.0",
|
||||
"typescript": "^4.6.2",
|
||||
"unist-util-visit": "2.0.3",
|
||||
"url-loader": "^4.1.1",
|
||||
"yaml": "^1.10.2",
|
||||
"yarn": "^1.22.18"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import React, { useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import styled from "styled-components";
|
||||
import { Experience } from "../../data/experiences"
|
||||
import { Experience } from "../../data/repos/experiences"
|
||||
import { SlideIn } from '../animations/slide-in';
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { Profile } from '../../data/profile';
|
||||
import { Profile } from '../../data/repos/profile';
|
||||
import { HeroBackground } from './background';
|
||||
import { SlideIn } from '../animations/slide-in';
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import Link from 'next/link';
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { Article } from '../../../data/articles';
|
||||
import { Article } from '../../../data/repos/articles';
|
||||
import { SlideIn } from '../../animations/slide-in';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
type Props = {
|
||||
article: Article;
|
||||
@@ -33,12 +34,15 @@ const Header = styled.h3`
|
||||
font-weight: 600;
|
||||
`;
|
||||
|
||||
const Published = styled.time`
|
||||
const Meta = styled.time`
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
padding-bottom: 1rem;
|
||||
`;
|
||||
|
||||
const Published = styled.time`
|
||||
`;
|
||||
|
||||
const Image = styled.div<{
|
||||
src: string
|
||||
}>`
|
||||
@@ -56,10 +60,15 @@ const ArticleTile: React.FC<Props> = ({ article }) => (
|
||||
<Link href={`/articles/${article.id}`}>
|
||||
<Wrapper>
|
||||
<Inner>
|
||||
<Meta>
|
||||
<Published>{formatDistanceToNow(new Date(article.published || 0), { addSuffix: true })}</Published>
|
||||
</Meta>
|
||||
<Header>
|
||||
{article.title}
|
||||
</Header>
|
||||
<Published>{article.published}</Published>
|
||||
<Meta>
|
||||
<br />{article.stats.minutes.toFixed(0)} min read
|
||||
</Meta>
|
||||
</Inner>
|
||||
{article.cover && (
|
||||
<ImageWrapper>
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import yaml from 'yaml';
|
||||
import { remark } from 'remark';
|
||||
import remarkHtml from 'remark-html';
|
||||
import behead from 'remark-behead';
|
||||
import { visit } from 'unist-util-visit';
|
||||
import { config } from '../config';
|
||||
|
||||
const imageModules = (require as any).context(
|
||||
'../../articles',
|
||||
true,
|
||||
/\.(png|jpeg)$/,
|
||||
)
|
||||
const images = imageModules.keys().map((key) => ({
|
||||
key,
|
||||
url: imageModules(key).default,
|
||||
}));
|
||||
|
||||
const replaceImages = ({ id }) => (tree: any) => {
|
||||
visit(tree, 'image', (node) => {
|
||||
if (!node.url.startsWith('./')) return;
|
||||
const correctedUrl = `.${path.resolve('/', id, node.url)}`;
|
||||
const image = images.find((i: any) => i.key === correctedUrl);
|
||||
node.url = image.url;
|
||||
})
|
||||
}
|
||||
|
||||
export type ArticlePart = string | {
|
||||
title?: string;
|
||||
file?: string;
|
||||
notes?: string;
|
||||
parts?: ArticlePart[];
|
||||
}
|
||||
|
||||
export type Article = {
|
||||
id: string;
|
||||
title: string;
|
||||
parts: ArticlePart[];
|
||||
cover?: string;
|
||||
summery?: string;
|
||||
published?: string;
|
||||
content: string;
|
||||
html: string;
|
||||
}
|
||||
|
||||
export class Articles {
|
||||
#articles: Promise<Article[]>;
|
||||
#images: typeof images;
|
||||
|
||||
constructor() {
|
||||
this.#images = images;
|
||||
this.#articles = this.#load();
|
||||
}
|
||||
|
||||
#loadArticle = async (location: string) => {
|
||||
const id = path.basename(location);
|
||||
const structureLocation = path.join(location, 'index.yml');
|
||||
const structureContent = await fs.readFile(structureLocation, 'utf-8');
|
||||
const structure = yaml.parse(structureContent) as Article;
|
||||
|
||||
const buildParts = async (part: ArticlePart, depth: number) => {
|
||||
const content = [];
|
||||
if (typeof part === 'string') {
|
||||
part = {
|
||||
file: part,
|
||||
}
|
||||
}
|
||||
if (part.title) {
|
||||
content.push(
|
||||
''.padStart(depth + 1, '#') + ' ' + part.title,
|
||||
);
|
||||
}
|
||||
if (part.file) {
|
||||
const fileLocation = path.join(location, part.file);
|
||||
const fileContent = await fs.readFile(fileLocation, 'utf-8');
|
||||
content.push(
|
||||
await remark().use(behead, { depth }).use(replaceImages, { id }).process(fileContent),
|
||||
);
|
||||
}
|
||||
if (part.parts) {
|
||||
content.push(
|
||||
...await Promise.all(
|
||||
part.parts.map((part) => buildParts(part, depth + 1)),
|
||||
),
|
||||
);
|
||||
}
|
||||
return content.join('\n');
|
||||
};
|
||||
|
||||
const articleContent = await Promise.all(
|
||||
structure.parts.map((part) => buildParts(part, 1)),
|
||||
);
|
||||
const cover = structure.cover
|
||||
? this.getImage(id, structure.cover)
|
||||
: null;
|
||||
|
||||
|
||||
const article: Article = {
|
||||
...structure,
|
||||
id,
|
||||
cover,
|
||||
content: articleContent.join('\n'),
|
||||
html: String(await remark().use(remarkHtml).process(articleContent.join('\n'))),
|
||||
};
|
||||
|
||||
return article;
|
||||
};
|
||||
|
||||
#load = async () => {
|
||||
const rootLocation = path.join(process.cwd(), 'articles');
|
||||
const articleLocations = await fs.readdir(rootLocation);
|
||||
const articles = await Promise.all(articleLocations.map(
|
||||
(location) => this.#loadArticle(path.join(rootLocation, location)),
|
||||
));
|
||||
return articles
|
||||
.sort((a, b) => new Date(b.published || 0).getTime() - new Date(a.published || 0).getTime())
|
||||
.filter(a => a.published || config.dev)
|
||||
|
||||
}
|
||||
|
||||
public getImage = (article: string, name: string) => {
|
||||
const url = `.${path.resolve('/', article, name)}`;
|
||||
const image = this.#images.find((i: any) => i.key === url);
|
||||
return image ? image.url : null;
|
||||
}
|
||||
|
||||
public get = async (id: string) => {
|
||||
const articles = await this.list();
|
||||
const article = articles.find(a => a.id === id);
|
||||
return article;
|
||||
};
|
||||
|
||||
public list = async () => {
|
||||
const articles = await this.#articles;
|
||||
return articles;
|
||||
}
|
||||
}
|
||||
|
||||
export const articleDB = new Articles();
|
||||
30
src/data/assets/WebpackAssets.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import path from 'path';
|
||||
import { AssetResolver } from './';
|
||||
|
||||
const assetModules = (require as any).context(
|
||||
'../../../content',
|
||||
true,
|
||||
/\.(png|jpe?g|svg|gif|tex\.yml)$/,
|
||||
)
|
||||
const assets = assetModules.keys().reduce((output, key: string) => ({
|
||||
...output,
|
||||
[path.resolve(
|
||||
'/',
|
||||
key,
|
||||
)]: assetModules(key).default || assetModules(key),
|
||||
}), {} as any);
|
||||
|
||||
class WebpackAssetResolver extends AssetResolver {
|
||||
#assets = assets;
|
||||
|
||||
public getPath = (...location: string[]) => {
|
||||
const target = path.resolve(
|
||||
'/',
|
||||
...location,
|
||||
);
|
||||
const assetModule = this.#assets[target];
|
||||
return assetModule;
|
||||
}
|
||||
}
|
||||
|
||||
export { WebpackAssetResolver };
|
||||
8
src/data/assets/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Service } from "typedi";
|
||||
|
||||
@Service()
|
||||
abstract class AssetResolver {
|
||||
public abstract getPath(...loc: string[]): string;
|
||||
}
|
||||
|
||||
export { AssetResolver };
|
||||
66
src/data/helpers/markdown.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import behead from 'remark-behead';
|
||||
import remark from 'remark';
|
||||
import visit from 'unist-util-visit';
|
||||
import { AssetResolver } from '../assets';
|
||||
|
||||
type MarkdownSection = string | {
|
||||
title?: string;
|
||||
file?: string;
|
||||
notes?: string;
|
||||
parts?: MarkdownSection[];
|
||||
};
|
||||
|
||||
const replaceImages = ({ location, assets }: any) => (tree: any) => {
|
||||
visit(tree, 'image', (node: any) => {
|
||||
if (!node.url.startsWith('./')) return;
|
||||
const resolvedLocation = assets.getPath(
|
||||
path.resolve('/', location, node.url),
|
||||
);
|
||||
node.url = resolvedLocation;
|
||||
})
|
||||
};
|
||||
|
||||
const generate = async (
|
||||
location: string,
|
||||
section: MarkdownSection,
|
||||
depth: number,
|
||||
assets: AssetResolver,
|
||||
) => {
|
||||
const result: string[] = [];
|
||||
if (typeof section === 'string') {
|
||||
section = {
|
||||
file: section,
|
||||
};
|
||||
}
|
||||
if (section.title) {
|
||||
result.push(''.padStart(depth, '#') + ' ' + section.title);
|
||||
depth += 1;
|
||||
}
|
||||
if (section.file) {
|
||||
const fileLocation = path.resolve('content', location, section.file);
|
||||
const fileContent = await fs.readFile(fileLocation, 'utf-8');
|
||||
const markdown = String(
|
||||
await remark()
|
||||
.use(behead, { depth })
|
||||
.use(replaceImages, { location, assets }).process(fileContent),
|
||||
);
|
||||
result.push(markdown);
|
||||
}
|
||||
if (section.parts) {
|
||||
const sectionMarkdown = await Promise.all(
|
||||
section.parts.map(s => generate(
|
||||
location,
|
||||
s,
|
||||
depth + 1,
|
||||
assets,
|
||||
)),
|
||||
);
|
||||
result.push(...sectionMarkdown);
|
||||
}
|
||||
return result.join('\n\n');
|
||||
}
|
||||
|
||||
export type { MarkdownSection };
|
||||
export { generate };
|
||||
100
src/data/repos/articles.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import { Service } from 'typedi';
|
||||
import yaml from 'yaml';
|
||||
import { config } from '../../config';
|
||||
import { AssetResolver } from '../assets';
|
||||
import { generate, MarkdownSection } from '../helpers/markdown';
|
||||
import readingTime from 'reading-time';
|
||||
|
||||
export type ArticlePart = string | {
|
||||
title?: string;
|
||||
file?: string;
|
||||
notes?: string;
|
||||
parts?: ArticlePart[];
|
||||
}
|
||||
|
||||
export type Article = {
|
||||
id: string;
|
||||
title: string;
|
||||
parts: MarkdownSection[];
|
||||
cover?: string;
|
||||
summery?: string;
|
||||
published?: string;
|
||||
content: string;
|
||||
stats: ReturnType<typeof readingTime>;
|
||||
pdfs: {
|
||||
a4: string;
|
||||
};
|
||||
}
|
||||
|
||||
@Service()
|
||||
export class ArticleDB {
|
||||
#articles: Promise<Article[]>;
|
||||
#assets: AssetResolver;
|
||||
|
||||
constructor(
|
||||
assets: AssetResolver,
|
||||
) {
|
||||
this.#assets = assets;
|
||||
this.#articles = this.#load();
|
||||
}
|
||||
|
||||
#loadArticle = async (location: string) => {
|
||||
const id = path.basename(location);
|
||||
const structureLocation = path.join('content', location, 'index.yml');
|
||||
const structureContent = await fs.readFile(structureLocation, 'utf-8');
|
||||
const structure = yaml.parse(structureContent) as Article;
|
||||
|
||||
const articleContent = await Promise.all(
|
||||
structure.parts.map((part) => generate(
|
||||
location,
|
||||
part,
|
||||
2,
|
||||
this.#assets,
|
||||
)),
|
||||
);
|
||||
const cover = structure.cover
|
||||
? this.#assets.getPath(path.resolve('/', location, structure.cover))
|
||||
: null;
|
||||
const stats = readingTime(articleContent.join('\n'));
|
||||
|
||||
const article: Article = {
|
||||
...structure,
|
||||
id,
|
||||
cover,
|
||||
content: articleContent.join('\n'),
|
||||
stats,
|
||||
pdfs: {
|
||||
a4: this.#assets.getPath(
|
||||
path.resolve('/', 'articles', id, 'a4.tex.yml'),
|
||||
) || null,
|
||||
}
|
||||
};
|
||||
|
||||
return article;
|
||||
};
|
||||
|
||||
#load = async () => {
|
||||
const rootLocation = path.join('content', 'articles');
|
||||
const articleLocations = await fs.readdir(rootLocation);
|
||||
const articles = await Promise.all(articleLocations.map(
|
||||
(location) => this.#loadArticle(path.join('articles', location)),
|
||||
));
|
||||
return articles
|
||||
.sort((a, b) => new Date(b.published || 0).getTime() - new Date(a.published || 0).getTime())
|
||||
.filter(a => a.published || config.dev)
|
||||
|
||||
}
|
||||
|
||||
public get = async (id: string) => {
|
||||
const articles = await this.list();
|
||||
const article = articles.find(a => a.id === id);
|
||||
return article;
|
||||
};
|
||||
|
||||
public list = async () => {
|
||||
const articles = await this.#articles;
|
||||
return articles;
|
||||
}
|
||||
}
|
||||
@@ -1,29 +1,9 @@
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import { Service } from 'typedi';
|
||||
import yaml from 'yaml';
|
||||
import { remark } from 'remark';
|
||||
import remarkHtml from 'remark-html';
|
||||
import behead from 'remark-behead';
|
||||
import { visit } from 'unist-util-visit';
|
||||
|
||||
const imageModules = (require as any).context(
|
||||
'../../experiences',
|
||||
true,
|
||||
/\.(png|jpeg)$/,
|
||||
)
|
||||
const images = imageModules.keys().map((key) => ({
|
||||
key,
|
||||
url: imageModules(key).default,
|
||||
}));
|
||||
|
||||
const replaceImages = ({ id }) => (tree: any) => {
|
||||
visit(tree, 'image', (node) => {
|
||||
if (!node.url.startsWith('./')) return;
|
||||
const correctedUrl = `.${path.resolve('/', id, node.url)}`;
|
||||
const image = images.find((i: any) => i.key === correctedUrl);
|
||||
node.url = image.url;
|
||||
})
|
||||
}
|
||||
import { AssetResolver } from '../assets';
|
||||
import { generate } from '../helpers/markdown';
|
||||
|
||||
export type Experience = {
|
||||
id: string;
|
||||
@@ -31,18 +11,20 @@ export type Experience = {
|
||||
name: string;
|
||||
}
|
||||
title: string;
|
||||
content: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
content: string;
|
||||
html: string;
|
||||
}
|
||||
|
||||
@Service()
|
||||
export class ExperienceDB {
|
||||
#experiences: Promise<Experience[]>;
|
||||
#images: typeof images;
|
||||
#assets: AssetResolver;
|
||||
|
||||
constructor() {
|
||||
this.#images = images;
|
||||
constructor(
|
||||
assets: AssetResolver,
|
||||
) {
|
||||
this.#assets = assets;
|
||||
this.#experiences = this.#load();
|
||||
}
|
||||
|
||||
@@ -52,22 +34,24 @@ export class ExperienceDB {
|
||||
const structureContent = await fs.readFile(structureLocation, 'utf-8');
|
||||
const structure = yaml.parse(structureContent) as Experience;
|
||||
|
||||
const fileLocation = path.join(location, 'description.md');
|
||||
const fileContent = await fs.readFile(fileLocation, 'utf-8');
|
||||
const content = await remark().use(replaceImages, { id }).process(fileContent);
|
||||
const content = await generate(
|
||||
location,
|
||||
'description.md',
|
||||
1,
|
||||
this.#assets,
|
||||
)
|
||||
|
||||
const experience: Experience = {
|
||||
...structure,
|
||||
id,
|
||||
content: String(content),
|
||||
html: String(await remark().use(remarkHtml).process(content)),
|
||||
};
|
||||
|
||||
return experience;
|
||||
};
|
||||
|
||||
#load = async () => {
|
||||
const rootLocation = path.join(process.cwd(), 'experiences');
|
||||
const rootLocation = path.join(process.cwd(), 'content', 'experiences');
|
||||
const articleLocations = await fs.readdir(rootLocation);
|
||||
const articles = await Promise.all(articleLocations.map(
|
||||
(location) => this.#loadExperience(path.join(rootLocation, location)),
|
||||
@@ -75,12 +59,6 @@ export class ExperienceDB {
|
||||
return articles.sort((a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime());
|
||||
}
|
||||
|
||||
public getImage = (article: string, name: string) => {
|
||||
const url = `.${path.resolve('/', article, name)}`;
|
||||
const image = this.#images.find((i: any) => i.key === url);
|
||||
return image ? image.url : null;
|
||||
}
|
||||
|
||||
public get = async (id: string) => {
|
||||
const articles = await this.list();
|
||||
const article = articles.find(a => a.id === id);
|
||||
@@ -92,5 +70,3 @@ export class ExperienceDB {
|
||||
return experiences;
|
||||
}
|
||||
}
|
||||
|
||||
export const experienceDB = new ExperienceDB();
|
||||
@@ -1,17 +1,9 @@
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import { Service } from 'typedi';
|
||||
import yaml from 'yaml';
|
||||
|
||||
const imageModules = (require as any).context(
|
||||
'../../profile',
|
||||
true,
|
||||
/\.(png|jpe?g|svg)$/,
|
||||
)
|
||||
const images = imageModules.keys().map((key) => ({
|
||||
key,
|
||||
url: imageModules(key).default,
|
||||
}));
|
||||
import { AssetResolver } from '../assets';
|
||||
|
||||
export type Profile = {
|
||||
name: string;
|
||||
@@ -27,27 +19,35 @@ export type Profile = {
|
||||
}[]
|
||||
}
|
||||
|
||||
@Service()
|
||||
export class ProfileDB {
|
||||
#profile: Promise<Profile>;
|
||||
#assets: AssetResolver;
|
||||
|
||||
constructor() {
|
||||
constructor(
|
||||
assets: AssetResolver,
|
||||
) {
|
||||
this.#assets = assets;
|
||||
this.#profile = this.#load();
|
||||
}
|
||||
|
||||
#load = async () => {
|
||||
const rootLocation = path.join(process.cwd(), 'profile');
|
||||
const rootLocation = path.join(process.cwd(), 'content', 'profile');
|
||||
const structureLocation = path.join(rootLocation, 'index.yml');
|
||||
const structureContent = await fs.readFile(structureLocation, 'utf-8');
|
||||
const structure = yaml.parse(structureContent) as Profile;
|
||||
if (structure.avatar) {
|
||||
const image = images.find((i: any) => i.key === `.${path.resolve('/', structure.avatar)}`)
|
||||
structure.avatar = image.url;
|
||||
const image = this.#assets.getPath(
|
||||
'profile',
|
||||
structure.avatar,
|
||||
)
|
||||
structure.avatar = image || null;
|
||||
}
|
||||
structure.social = structure.social.map((social) => {
|
||||
const image = images.find((i: any) => i.key === `.${path.resolve('/', social.logo)}`)
|
||||
const image = this.#assets.getPath('profile', social.logo);
|
||||
return {
|
||||
...social,
|
||||
logo: image?.url || null,
|
||||
logo: image || null,
|
||||
};
|
||||
})
|
||||
return structure;
|
||||
@@ -58,5 +58,3 @@ export class ProfileDB {
|
||||
return profile;
|
||||
};
|
||||
}
|
||||
|
||||
export const profileDB = new ProfileDB();
|
||||
3
src/latex/generators/Generator.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
type LatexGenerator<T = any> = (data: T, location: string) => Promise<string>;
|
||||
|
||||
export type { LatexGenerator };
|
||||
45
src/latex/generators/article/index.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { Article } from "../../../data/repos/articles";
|
||||
import fs from 'fs-extra';
|
||||
import { LatexGenerator } from "../Generator";
|
||||
import { generate } from '../../../data/helpers/markdown';
|
||||
import path from 'path';
|
||||
import yaml from 'yaml';
|
||||
import { fromMarkdown } from "../../helpers/convert";
|
||||
|
||||
const assets = {
|
||||
getPath: (source: string) => {
|
||||
return source;
|
||||
},
|
||||
}
|
||||
type Data = {
|
||||
structure: string;
|
||||
}
|
||||
const article: LatexGenerator<Data> = async (data, location) => {
|
||||
const dir = path.dirname(location);
|
||||
const structureLocation = path.resolve(dir, data.structure);
|
||||
const structureContent = await fs.readFile(structureLocation, 'utf-8');
|
||||
const structure = yaml.parse(structureContent) as Article;
|
||||
const sections = await Promise.all(structure.parts.map(
|
||||
part => generate(
|
||||
dir,
|
||||
part,
|
||||
1,
|
||||
assets,
|
||||
),
|
||||
));
|
||||
const content = sections.join('\n\n');
|
||||
|
||||
return `
|
||||
\\documentclass[twocolumn]{article}
|
||||
\\usepackage{graphicx}
|
||||
\\usepackage[skip=5pt plus1pt, indent=10pt]{parskip}
|
||||
\\setlength\\columnsep{1cm}
|
||||
\\title{${structure.title}}
|
||||
\\begin{document}
|
||||
\\maketitle
|
||||
${fromMarkdown(content, 0)}
|
||||
\\end{document}
|
||||
`;
|
||||
}
|
||||
|
||||
export { article };
|
||||
10
src/latex/generators/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { LatexGenerator } from "./Generator";
|
||||
import { article } from './article';
|
||||
import { resume } from './resume';
|
||||
|
||||
const generators: {[name: string]: LatexGenerator} = {
|
||||
article,
|
||||
resume,
|
||||
};
|
||||
|
||||
export { generators };
|
||||
35
src/latex/generators/resume/index.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { LatexGenerator } from "../Generator";
|
||||
import Container from 'typedi';
|
||||
import { AssetResolver } from '../../../data/assets';
|
||||
import { ProfileDB } from '../../../data/repos/profile';
|
||||
import { ExperienceDB } from '../../../data/repos/experiences';
|
||||
|
||||
const assets = {
|
||||
getPath: (source: string) => {
|
||||
return source;
|
||||
},
|
||||
}
|
||||
type Data = {
|
||||
structure: string;
|
||||
}
|
||||
const resume: LatexGenerator<Data> = async (data, location) => {
|
||||
Container.set(AssetResolver, assets);
|
||||
const profileDB = Container.get(ProfileDB);
|
||||
const experienceDB = Container.get(ExperienceDB);
|
||||
|
||||
const profile = await profileDB.get();
|
||||
const experiences = await experienceDB.list();
|
||||
|
||||
return `
|
||||
\\documentclass[twocolumn]{article}
|
||||
\\usepackage{graphicx}
|
||||
\\usepackage[skip=5pt plus1pt, indent=10pt]{parskip}
|
||||
\\setlength\\columnsep{1cm}
|
||||
\\title{${profile.name}}
|
||||
\\begin{document}
|
||||
\\maketitle
|
||||
\\end{document}
|
||||
`;
|
||||
}
|
||||
|
||||
export { resume };
|
||||
82
src/latex/helpers/convert.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { existsSync } from 'fs-extra';
|
||||
import { decode } from 'html-entities';
|
||||
import { marked } from 'marked';
|
||||
|
||||
const latexTypes = [
|
||||
'',
|
||||
'section',
|
||||
'subsection',
|
||||
'paragraph',
|
||||
];
|
||||
|
||||
const sanitize = (text?: string) => {
|
||||
if (!text) return '';
|
||||
return decode(text)
|
||||
.replace('&', '\\&')
|
||||
.replace('_', '\\_')
|
||||
.replace(/([^\\])\}/g, '$1\\}')
|
||||
.replace(/([^\\])\{/g, '$1\\{')
|
||||
.replace(/[^\\]\[/g, '\\[')
|
||||
.replace('#', '\\#');
|
||||
};
|
||||
|
||||
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);
|
||||
},
|
||||
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) => {
|
||||
console.log('link', link);
|
||||
if (!existsSync(link)) {
|
||||
return 'Online image not supported';
|
||||
}
|
||||
return `\\begin{figure}[h!]
|
||||
\\includegraphics[width=0.5\\textwidth]{${link}}
|
||||
\\centering
|
||||
\\end{figure}
|
||||
`
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
export const fromMarkdown = (md: string, depth: number) => {
|
||||
marked.use({ renderer: renderer(depth) });
|
||||
return marked.parse(md);
|
||||
}
|
||||
12
src/latex/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { generators } from "./generators";
|
||||
import yaml from 'yaml';
|
||||
import path from 'path';
|
||||
|
||||
const generateLatex = async (source: string, location: string) => {
|
||||
const definition = yaml.parse(source);
|
||||
const generator = generators[definition.generator];
|
||||
const latex = await generator(definition.data, location);
|
||||
return latex;
|
||||
}
|
||||
|
||||
export { generateLatex };
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'reflect-metadata';
|
||||
import Document from 'next/document';
|
||||
import { ServerStyleSheet } from 'styled-components';
|
||||
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { Article, articleDB } from '../../data/articles';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { Content } from '../../components/content';
|
||||
import ReactMarkdown, { Components } from 'react-markdown';
|
||||
import { Navigation } from '../../components/navigation';
|
||||
import Head from 'next/head';
|
||||
import { Profile, profileDB } from '../../data/profile';
|
||||
import { Article, ArticleDB } from '../../data/repos/articles';
|
||||
import { Profile, ProfileDB } from '../../data/repos/profile';
|
||||
import Container from 'typedi';
|
||||
import { AssetResolver } from '../../data/assets';
|
||||
import { WebpackAssetResolver } from '../../data/assets/WebpackAssets';
|
||||
|
||||
type Props = {
|
||||
article: Article;
|
||||
@@ -51,10 +55,13 @@ const Wrapper = styled.article`
|
||||
}
|
||||
`;
|
||||
|
||||
const Published = styled.time`
|
||||
const Meta = styled.div`
|
||||
font-size: 0.8rem;
|
||||
`;
|
||||
|
||||
const Published = styled.time`
|
||||
`;
|
||||
|
||||
const Cover = styled.img`
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
@@ -82,7 +89,13 @@ const ArticleView: React.FC<Props> = ({
|
||||
<Content>
|
||||
<Wrapper>
|
||||
<h1>{article.title}</h1>
|
||||
<Published>3 days ago</Published>
|
||||
<Meta>
|
||||
<Published>{formatDistanceToNow(new Date(article.published || 0), { addSuffix: true })}</Published>
|
||||
{' '} - {article.stats.minutes.toFixed(0)} minute read
|
||||
{' '} - {article.pdfs.a4 &&(
|
||||
<a href={article.pdfs.a4} target="_blank">Download as PDF</a>
|
||||
)}
|
||||
</Meta>
|
||||
<Cover src={article.cover} />
|
||||
<ReactMarkdown components={components}>
|
||||
{article.content}
|
||||
@@ -94,6 +107,8 @@ const ArticleView: React.FC<Props> = ({
|
||||
};
|
||||
|
||||
export async function getStaticPaths() {
|
||||
Container.set(AssetResolver, new WebpackAssetResolver());
|
||||
const articleDB = Container.get(ArticleDB);
|
||||
const articles = await articleDB.list();
|
||||
return {
|
||||
paths: articles.map(a => `/articles/${a.id}`),
|
||||
@@ -103,6 +118,9 @@ export async function getStaticPaths() {
|
||||
|
||||
export async function getStaticProps({ params }: any) {
|
||||
const { id } = params;
|
||||
Container.set(AssetResolver, new WebpackAssetResolver());
|
||||
const articleDB = Container.get(ArticleDB);
|
||||
const profileDB = Container.get(ProfileDB);
|
||||
const article = await articleDB.get(id);
|
||||
const profile = await profileDB.get();
|
||||
return {
|
||||
|
||||
@@ -1,22 +1,28 @@
|
||||
import Head from 'next/head';
|
||||
import React from 'react';
|
||||
import Container from 'typedi';
|
||||
import { Content } from '../components/content';
|
||||
import { Experiences } from '../components/experiences';
|
||||
import { Featured } from '../components/featured';
|
||||
import { Hero } from '../components/hero';
|
||||
import { ArticleTile } from '../components/tiles/article';
|
||||
import { Article, articleDB } from '../data/articles';
|
||||
import { Experience, experienceDB } from '../data/experiences';
|
||||
import { Profile, profileDB } from '../data/profile';
|
||||
import { AssetResolver } from '../data/assets';
|
||||
import { WebpackAssetResolver } from '../data/assets/WebpackAssets';
|
||||
import { Article, ArticleDB } from '../data/repos/articles';
|
||||
import { Experience, ExperienceDB } from '../data/repos/experiences';
|
||||
import { Profile, ProfileDB } from '../data/repos/profile';
|
||||
|
||||
type Props = {
|
||||
resume: string;
|
||||
articles: Article[];
|
||||
profile: Profile;
|
||||
experiences: Experience[];
|
||||
};
|
||||
|
||||
|
||||
const Home: React.FC<Props> = ({ articles, profile, experiences }) => (
|
||||
const Home: React.FC<Props> = ({ resume, articles, profile, experiences }) => {
|
||||
console.log('resume', resume);
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{profile.name} - {profile.tagline}</title>
|
||||
@@ -32,13 +38,21 @@ const Home: React.FC<Props> = ({ articles, profile, experiences }) => (
|
||||
</Content>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export async function getStaticProps() {
|
||||
Container.set(AssetResolver, new WebpackAssetResolver());
|
||||
const articleDB = Container.get(ArticleDB);
|
||||
const profileDB = Container.get(ProfileDB);
|
||||
const experienceDB = Container.get(ExperienceDB);
|
||||
const assets = Container.get(AssetResolver);
|
||||
const articles = await articleDB.list();
|
||||
const profile = await profileDB.get();
|
||||
const experiences = await experienceDB.list();
|
||||
const resume = assets.getPath('profile', 'a4.tex.yml');
|
||||
return {
|
||||
props: {
|
||||
resume,
|
||||
profile,
|
||||
articles,
|
||||
experiences,
|
||||
|
||||
@@ -10,14 +10,16 @@
|
||||
"skipLibCheck": true,
|
||||
"strict": false,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true
|
||||
"incremental": true,
|
||||
"module": "esnext"
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
|
||||