feat: added latex generation

This commit is contained in:
Morten Olsen
2022-03-29 23:01:49 +02:00
parent 90fdaeb406
commit d1d77ed915
68 changed files with 1506 additions and 290 deletions

View File

@@ -1,4 +1,11 @@
{ {
"presets": ["next/babel"], "presets": [
"plugins": [["styled-components", { "ssr": true }]] ["next/babel", {
}]
],
"plugins": [
"babel-plugin-transform-typescript-metadata",
["@babel/plugin-proposal-decorators", { "legacy": true }],
["styled-components", { "ssr": true }]
]
} }

View 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;

View 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);
}

View File

@@ -0,0 +1,3 @@
data:
structure: ./index.yml
generator: article

View File

Before

Width:  |  Height:  |  Size: 168 KiB

After

Width:  |  Height:  |  Size: 168 KiB

View File

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 68 KiB

View File

@@ -1,5 +1,5 @@
title: How to hire engineers, by an engineer title: How to hire engineers, by an engineer
cover: cover.png cover: cover.png
published: 2021-03-15 published: 2022-03-16
parts: parts:
- main.md - main.md

View File

@@ -0,0 +1,3 @@
data:
structure: ./index.yml
generator: article

View File

@@ -0,0 +1,3 @@
data:
structure: ./index.yml
generator: article

View File

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -1,5 +1,5 @@
title: My home runs Redux title: My home runs Redux
cover: cover.png cover: cover.png
published: 2021-03-15 published: 2022-03-15
parts: parts:
- main.md - main.md

View File

@@ -56,7 +56,7 @@ Now comes the part I have feared, where I need to draw a diagram.
...sorry ...sorry
![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/nhzfqbddv4otq6h4zprf.png) ![Image description](./graph.png)
So this shows our final setup. So this shows our final setup.

View File

@@ -0,0 +1 @@
generator: resume

View File

Before

Width:  |  Height:  |  Size: 559 B

After

Width:  |  Height:  |  Size: 559 B

View File

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

Before

Width:  |  Height:  |  Size: 553 B

After

Width:  |  Height:  |  Size: 553 B

View File

Before

Width:  |  Height:  |  Size: 558 KiB

After

Width:  |  Height:  |  Size: 558 KiB

View File

@@ -1,6 +1,7 @@
const withPlugins = require("next-compose-plugins"); const withPlugins = require("next-compose-plugins");
const withImages = require("./config/plugins/withImages.js"); const withImages = require("./config/plugins/withImages.js");
const withLatex = require('./config/plugins/withLatex');
const withBundleAnalyzer = require("@next/bundle-analyzer")({ const withBundleAnalyzer = require("@next/bundle-analyzer")({
enabled: process.env.ANALYZE === "true", enabled: process.env.ANALYZE === "true",
}); });
@@ -13,6 +14,7 @@ const nextConfig = {
}; };
module.exports = withPlugins([ module.exports = withPlugins([
withLatex,
[withImages,{ [withImages,{
esModule: true, // using ES modules is beneficial in the case of module concatenation and tree shaking. esModule: true, // using ES modules is beneficial in the case of module concatenation and tree shaking.
inlineImageLimit: 0, // disable image inlining to data:base64 inlineImageLimit: 0, // disable image inlining to data:base64

View File

@@ -11,16 +11,25 @@
"@fontsource/pacifico": "^4.5.3", "@fontsource/pacifico": "^4.5.3",
"@react-three/drei": "^8.18.6", "@react-three/drei": "^8.18.6",
"@react-three/fiber": "^7.0.26", "@react-three/fiber": "^7.0.26",
"date-fns": "^2.28.0",
"next": "^12.1.0", "next": "^12.1.0",
"node-latex": "^3.1.0",
"react": "^17.0.2", "react": "^17.0.2",
"react-dom": "^17.0.2", "react-dom": "^17.0.2",
"react-markdown": "^8.0.1", "react-markdown": "^8.0.1",
"reflect-metadata": "^0.1.13",
"rehype-img-size": "^1.0.1", "rehype-img-size": "^1.0.1",
"remark-html": "^15.0.1", "remark-html": "^15.0.1",
"styled-components": "^5.3.3", "styled-components": "^5.3.3",
"three": "^0.138.3" "three": "^0.138.3",
"typedi": "^0.10.0"
}, },
"devDependencies": { "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", "@next/bundle-analyzer": "^12.1.0",
"@types/fs-extra": "^9.0.13", "@types/fs-extra": "^9.0.13",
"@types/marked": "^4.0.3", "@types/marked": "^4.0.3",
@@ -29,17 +38,24 @@
"@types/three": "^0.138.0", "@types/three": "^0.138.0",
"@types/yaml": "^1.9.7", "@types/yaml": "^1.9.7",
"babel-plugin-styled-components": "^2.0.6", "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", "file-loader": "^6.2.0",
"framer-motion": "^6.2.8", "framer-motion": "^6.2.8",
"fs-extra": "^10.0.1", "fs-extra": "^10.0.1",
"html-entities": "^2.3.3",
"marked": "^4.0.12", "marked": "^4.0.12",
"next-compose-plugins": "^2.2.1", "next-compose-plugins": "^2.2.1",
"next-images": "^1.8.4", "next-images": "^1.8.4",
"next-mdx-remote": "^4.0.0", "next-mdx-remote": "^4.0.0",
"remark": "^14.0.2", "os": "^0.1.2",
"remark-behead": "^3.0.0", "reading-time": "^1.5.0",
"remark": "^13.0.0",
"remark-behead": "^2.3.3",
"remark-slug": "^7.0.1", "remark-slug": "^7.0.1",
"ts-node": "^10.7.0",
"typescript": "^4.6.2", "typescript": "^4.6.2",
"unist-util-visit": "2.0.3",
"url-loader": "^4.1.1", "url-loader": "^4.1.1",
"yaml": "^1.10.2", "yaml": "^1.10.2",
"yarn": "^1.22.18" "yarn": "^1.22.18"

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useRef, useState } from "react"; import React, { useState } from "react";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import styled from "styled-components"; import styled from "styled-components";
import { Experience } from "../../data/experiences" import { Experience } from "../../data/repos/experiences"
import { SlideIn } from '../animations/slide-in'; import { SlideIn } from '../animations/slide-in';
type Props = { type Props = {

View File

@@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { Profile } from '../../data/profile'; import { Profile } from '../../data/repos/profile';
import { HeroBackground } from './background'; import { HeroBackground } from './background';
import { SlideIn } from '../animations/slide-in'; import { SlideIn } from '../animations/slide-in';

View File

@@ -1,8 +1,9 @@
import Link from 'next/link'; import Link from 'next/link';
import React from 'react'; import React from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { Article } from '../../../data/articles'; import { Article } from '../../../data/repos/articles';
import { SlideIn } from '../../animations/slide-in'; import { SlideIn } from '../../animations/slide-in';
import { formatDistanceToNow } from 'date-fns';
type Props = { type Props = {
article: Article; article: Article;
@@ -33,12 +34,15 @@ const Header = styled.h3`
font-weight: 600; font-weight: 600;
`; `;
const Published = styled.time` const Meta = styled.time`
display: block; display: block;
font-size: 0.8rem; font-size: 0.8rem;
padding-bottom: 1rem; padding-bottom: 1rem;
`; `;
const Published = styled.time`
`;
const Image = styled.div<{ const Image = styled.div<{
src: string src: string
}>` }>`
@@ -56,10 +60,15 @@ const ArticleTile: React.FC<Props> = ({ article }) => (
<Link href={`/articles/${article.id}`}> <Link href={`/articles/${article.id}`}>
<Wrapper> <Wrapper>
<Inner> <Inner>
<Meta>
<Published>{formatDistanceToNow(new Date(article.published || 0), { addSuffix: true })}</Published>
</Meta>
<Header> <Header>
{article.title} {article.title}
</Header> </Header>
<Published>{article.published}</Published> <Meta>
<br />{article.stats.minutes.toFixed(0)} min read
</Meta>
</Inner> </Inner>
{article.cover && ( {article.cover && (
<ImageWrapper> <ImageWrapper>

View File

@@ -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();

View 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
View File

@@ -0,0 +1,8 @@
import { Service } from "typedi";
@Service()
abstract class AssetResolver {
public abstract getPath(...loc: string[]): string;
}
export { AssetResolver };

View 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
View 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;
}
}

View File

@@ -1,29 +1,9 @@
import fs from 'fs-extra'; import fs from 'fs-extra';
import path from 'path'; import path from 'path';
import { Service } from 'typedi';
import yaml from 'yaml'; import yaml from 'yaml';
import { remark } from 'remark'; import { AssetResolver } from '../assets';
import remarkHtml from 'remark-html'; import { generate } from '../helpers/markdown';
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;
})
}
export type Experience = { export type Experience = {
id: string; id: string;
@@ -31,18 +11,20 @@ export type Experience = {
name: string; name: string;
} }
title: string; title: string;
content: string;
startDate: string; startDate: string;
endDate: string; endDate: string;
content: string;
html: string;
} }
@Service()
export class ExperienceDB { export class ExperienceDB {
#experiences: Promise<Experience[]>; #experiences: Promise<Experience[]>;
#images: typeof images; #assets: AssetResolver;
constructor() { constructor(
this.#images = images; assets: AssetResolver,
) {
this.#assets = assets;
this.#experiences = this.#load(); this.#experiences = this.#load();
} }
@@ -52,22 +34,24 @@ export class ExperienceDB {
const structureContent = await fs.readFile(structureLocation, 'utf-8'); const structureContent = await fs.readFile(structureLocation, 'utf-8');
const structure = yaml.parse(structureContent) as Experience; const structure = yaml.parse(structureContent) as Experience;
const fileLocation = path.join(location, 'description.md'); const content = await generate(
const fileContent = await fs.readFile(fileLocation, 'utf-8'); location,
const content = await remark().use(replaceImages, { id }).process(fileContent); 'description.md',
1,
this.#assets,
)
const experience: Experience = { const experience: Experience = {
...structure, ...structure,
id, id,
content: String(content), content: String(content),
html: String(await remark().use(remarkHtml).process(content)),
}; };
return experience; return experience;
}; };
#load = async () => { #load = async () => {
const rootLocation = path.join(process.cwd(), 'experiences'); const rootLocation = path.join(process.cwd(), 'content', 'experiences');
const articleLocations = await fs.readdir(rootLocation); const articleLocations = await fs.readdir(rootLocation);
const articles = await Promise.all(articleLocations.map( const articles = await Promise.all(articleLocations.map(
(location) => this.#loadExperience(path.join(rootLocation, location)), (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()); 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) => { public get = async (id: string) => {
const articles = await this.list(); const articles = await this.list();
const article = articles.find(a => a.id === id); const article = articles.find(a => a.id === id);
@@ -92,5 +70,3 @@ export class ExperienceDB {
return experiences; return experiences;
} }
} }
export const experienceDB = new ExperienceDB();

View File

@@ -1,17 +1,9 @@
import fs from 'fs-extra'; import fs from 'fs-extra';
import path from 'path'; import path from 'path';
import { Service } from 'typedi';
import yaml from 'yaml'; import yaml from 'yaml';
import { AssetResolver } from '../assets';
const imageModules = (require as any).context(
'../../profile',
true,
/\.(png|jpe?g|svg)$/,
)
const images = imageModules.keys().map((key) => ({
key,
url: imageModules(key).default,
}));
export type Profile = { export type Profile = {
name: string; name: string;
@@ -27,27 +19,35 @@ export type Profile = {
}[] }[]
} }
@Service()
export class ProfileDB { export class ProfileDB {
#profile: Promise<Profile>; #profile: Promise<Profile>;
#assets: AssetResolver;
constructor() { constructor(
assets: AssetResolver,
) {
this.#assets = assets;
this.#profile = this.#load(); this.#profile = this.#load();
} }
#load = async () => { #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 structureLocation = path.join(rootLocation, 'index.yml');
const structureContent = await fs.readFile(structureLocation, 'utf-8'); const structureContent = await fs.readFile(structureLocation, 'utf-8');
const structure = yaml.parse(structureContent) as Profile; const structure = yaml.parse(structureContent) as Profile;
if (structure.avatar) { if (structure.avatar) {
const image = images.find((i: any) => i.key === `.${path.resolve('/', structure.avatar)}`) const image = this.#assets.getPath(
structure.avatar = image.url; 'profile',
structure.avatar,
)
structure.avatar = image || null;
} }
structure.social = structure.social.map((social) => { 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 { return {
...social, ...social,
logo: image?.url || null, logo: image || null,
}; };
}) })
return structure; return structure;
@@ -58,5 +58,3 @@ export class ProfileDB {
return profile; return profile;
}; };
} }
export const profileDB = new ProfileDB();

View File

@@ -0,0 +1,3 @@
type LatexGenerator<T = any> = (data: T, location: string) => Promise<string>;
export type { LatexGenerator };

View 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 };

View 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 };

View 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 };

View 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
View 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 };

View File

@@ -1,3 +1,4 @@
import 'reflect-metadata';
import Document from 'next/document'; import Document from 'next/document';
import { ServerStyleSheet } from 'styled-components'; import { ServerStyleSheet } from 'styled-components';

View File

@@ -1,11 +1,15 @@
import React from 'react'; import React from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { Article, articleDB } from '../../data/articles'; import { formatDistanceToNow } from 'date-fns';
import { Content } from '../../components/content'; import { Content } from '../../components/content';
import ReactMarkdown, { Components } from 'react-markdown'; import ReactMarkdown, { Components } from 'react-markdown';
import { Navigation } from '../../components/navigation'; import { Navigation } from '../../components/navigation';
import Head from 'next/head'; 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 = { type Props = {
article: Article; article: Article;
@@ -51,10 +55,13 @@ const Wrapper = styled.article`
} }
`; `;
const Published = styled.time` const Meta = styled.div`
font-size: 0.8rem; font-size: 0.8rem;
`; `;
const Published = styled.time`
`;
const Cover = styled.img` const Cover = styled.img`
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
@@ -82,7 +89,13 @@ const ArticleView: React.FC<Props> = ({
<Content> <Content>
<Wrapper> <Wrapper>
<h1>{article.title}</h1> <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} /> <Cover src={article.cover} />
<ReactMarkdown components={components}> <ReactMarkdown components={components}>
{article.content} {article.content}
@@ -94,6 +107,8 @@ const ArticleView: React.FC<Props> = ({
}; };
export async function getStaticPaths() { export async function getStaticPaths() {
Container.set(AssetResolver, new WebpackAssetResolver());
const articleDB = Container.get(ArticleDB);
const articles = await articleDB.list(); const articles = await articleDB.list();
return { return {
paths: articles.map(a => `/articles/${a.id}`), paths: articles.map(a => `/articles/${a.id}`),
@@ -103,6 +118,9 @@ export async function getStaticPaths() {
export async function getStaticProps({ params }: any) { export async function getStaticProps({ params }: any) {
const { id } = params; 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 article = await articleDB.get(id);
const profile = await profileDB.get(); const profile = await profileDB.get();
return { return {

View File

@@ -1,22 +1,28 @@
import Head from 'next/head'; import Head from 'next/head';
import React from 'react'; import React from 'react';
import Container from 'typedi';
import { Content } from '../components/content'; import { Content } from '../components/content';
import { Experiences } from '../components/experiences'; import { Experiences } from '../components/experiences';
import { Featured } from '../components/featured'; import { Featured } from '../components/featured';
import { Hero } from '../components/hero'; import { Hero } from '../components/hero';
import { ArticleTile } from '../components/tiles/article'; import { ArticleTile } from '../components/tiles/article';
import { Article, articleDB } from '../data/articles'; import { AssetResolver } from '../data/assets';
import { Experience, experienceDB } from '../data/experiences'; import { WebpackAssetResolver } from '../data/assets/WebpackAssets';
import { Profile, profileDB } from '../data/profile'; import { Article, ArticleDB } from '../data/repos/articles';
import { Experience, ExperienceDB } from '../data/repos/experiences';
import { Profile, ProfileDB } from '../data/repos/profile';
type Props = { type Props = {
resume: string;
articles: Article[]; articles: Article[];
profile: Profile; profile: Profile;
experiences: Experience[]; 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> <Head>
<title>{profile.name} - {profile.tagline}</title> <title>{profile.name} - {profile.tagline}</title>
@@ -31,14 +37,22 @@ const Home: React.FC<Props> = ({ articles, profile, experiences }) => (
<Experiences experiences={experiences} /> <Experiences experiences={experiences} />
</Content> </Content>
</> </>
); );
};
export async function getStaticProps() { 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 articles = await articleDB.list();
const profile = await profileDB.get(); const profile = await profileDB.get();
const experiences = await experienceDB.list(); const experiences = await experienceDB.list();
const resume = assets.getPath('profile', 'a4.tex.yml');
return { return {
props: { props: {
resume,
profile, profile,
articles, articles,
experiences, experiences,

View File

@@ -10,14 +10,16 @@
"skipLibCheck": true, "skipLibCheck": true,
"strict": false, "strict": false,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"noEmit": true, "noEmit": true,
"esModuleInterop": true, "esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node", "moduleResolution": "node",
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"jsx": "preserve", "jsx": "preserve",
"incremental": true "incremental": true,
"module": "esnext"
}, },
"include": [ "include": [
"next-env.d.ts", "next-env.d.ts",

950
yarn.lock

File diff suppressed because it is too large Load Diff