mirror of
https://github.com/morten-olsen/morten-olsen.github.io.git
synced 2026-02-08 01:46:28 +01:00
feat: init
This commit is contained in:
5
packages/goodwrites-cli/.gitignore
vendored
Normal file
5
packages/goodwrites-cli/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
|
||||
/node_modules/
|
||||
/*.logs
|
||||
/.yarn/
|
||||
/dist/
|
||||
2
packages/goodwrites-cli/bin/index.js
Executable file
2
packages/goodwrites-cli/bin/index.js
Executable file
@@ -0,0 +1,2 @@
|
||||
#!/usr/bin/env node
|
||||
require('../dist/index.js');
|
||||
20
packages/goodwrites-cli/package.json
Normal file
20
packages/goodwrites-cli/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@morten-olsen/goodwrites-cli",
|
||||
"packageManager": "yarn@3.1.0",
|
||||
"bin": {
|
||||
"goodwrites": "./bin/index.js"
|
||||
},
|
||||
"scripts": {
|
||||
"cli": "./bin/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@morten-olsen/goodwrites": "workspace:^",
|
||||
"@morten-olsen/goodwrites-viewer": "workspace:^",
|
||||
"commander": "^9.4.1",
|
||||
"fs-extra": "^11.1.0",
|
||||
"yaml": "^2.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/fs-extra": "^9.0.13"
|
||||
}
|
||||
}
|
||||
31
packages/goodwrites-cli/src/index.ts
Normal file
31
packages/goodwrites-cli/src/index.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { program } from 'commander';
|
||||
import { resolve, dirname } from 'path';
|
||||
import { createServer } from '@morten-olsen/goodwrites-viewer';
|
||||
import { readFile } from 'fs-extra';
|
||||
import yaml from 'yaml';
|
||||
import { parseDocument } from '@morten-olsen/goodwrites';
|
||||
|
||||
const dev = program.command('dev <file>');
|
||||
dev.action(async (file) => {
|
||||
const fileLocation = resolve(process.cwd(), file);
|
||||
const server = createServer({
|
||||
dev: true,
|
||||
documentLocation: resolve(fileLocation),
|
||||
});
|
||||
server.listen(4005);
|
||||
});
|
||||
|
||||
const build = program.command('build <file>');
|
||||
build.action(async (file) => {
|
||||
const fileLocation = resolve(process.cwd(), file);
|
||||
const location = dirname(fileLocation);
|
||||
const content = await readFile(fileLocation, 'utf-8');
|
||||
const document = yaml.parse(content);
|
||||
const parsed = await parseDocument({
|
||||
document,
|
||||
location,
|
||||
});
|
||||
console.log(parsed.content);
|
||||
});
|
||||
|
||||
program.parse(process.argv);
|
||||
9
packages/goodwrites-cli/tsconfig.json
Normal file
9
packages/goodwrites-cli/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist"
|
||||
},
|
||||
"include": [
|
||||
"./src"
|
||||
]
|
||||
}
|
||||
5
packages/goodwrites-latex/.gitignore
vendored
Normal file
5
packages/goodwrites-latex/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
|
||||
/node_modules/
|
||||
/*.logs
|
||||
/.yarn/
|
||||
/dist/
|
||||
14
packages/goodwrites-latex/package.json
Normal file
14
packages/goodwrites-latex/package.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@morten-olsen/goodwrites-latex",
|
||||
"main": "./dist/index.js",
|
||||
"devDependencies": {
|
||||
"@types/marked": "^4.0.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"@morten-olsen/goodwrites": "workspace:^",
|
||||
"html-entities": "^2.3.3",
|
||||
"marked": "^4.0.12",
|
||||
"node-latex": "^3.1.0",
|
||||
"stream": "^0.0.2"
|
||||
}
|
||||
}
|
||||
1
packages/goodwrites-latex/src/index.ts
Normal file
1
packages/goodwrites-latex/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './parser';
|
||||
67
packages/goodwrites-latex/src/parser/index.ts
Normal file
67
packages/goodwrites-latex/src/parser/index.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { DocumentResult, replaceImages } from '@morten-olsen/goodwrites';
|
||||
import { marked } from 'marked';
|
||||
import { resolve } from 'path';
|
||||
import { latexToPdf, renderer, Renderer, sanitize } from './utils';
|
||||
|
||||
type WrapOptions = {
|
||||
body: string;
|
||||
cover: {
|
||||
title: string;
|
||||
image?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type Parser = {
|
||||
wrap?: (options: WrapOptions) => string;
|
||||
renderer?: Renderer;
|
||||
};
|
||||
|
||||
const defaultWrap = (options: WrapOptions) => {
|
||||
return `
|
||||
\\documentclass{article}
|
||||
\\usepackage{graphicx}
|
||||
\\usepackage{hyperref}
|
||||
\\title{${sanitize(options.cover.title)}}
|
||||
\\begin{document}
|
||||
\\maketitle
|
||||
${
|
||||
options.cover.image
|
||||
? `\\includegraphics[width=\\textwidth]{${options.cover.image}}`
|
||||
: ''
|
||||
}
|
||||
${options.body}
|
||||
\\end{document}
|
||||
`;
|
||||
};
|
||||
|
||||
const toLatex = async (document: DocumentResult, parser?: Parser) => {
|
||||
const { content } = await replaceImages(document, {
|
||||
replaceImage: (a) => a,
|
||||
});
|
||||
const wrap = parser?.wrap || defaultWrap;
|
||||
const baseRender = renderer(0);
|
||||
const render = {
|
||||
...baseRender,
|
||||
...(parser?.renderer?.(0) || {}),
|
||||
};
|
||||
marked.use({ renderer: render });
|
||||
const markdown = marked.parse(content);
|
||||
const wrapped = wrap({
|
||||
body: markdown,
|
||||
cover: {
|
||||
title: document.title,
|
||||
image: document.cover ? resolve(document.cwd, document.cover) : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
return wrapped;
|
||||
};
|
||||
|
||||
const toPdf = async (document: DocumentResult, parser?: Parser) => {
|
||||
const latex = await toLatex(document, parser);
|
||||
const pdf = await latexToPdf(latex);
|
||||
return pdf;
|
||||
};
|
||||
|
||||
export type { Parser, WrapOptions };
|
||||
export { toLatex, toPdf };
|
||||
118
packages/goodwrites-latex/src/parser/utils.ts
Normal file
118
packages/goodwrites-latex/src/parser/utils.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { decode } from 'html-entities';
|
||||
import latex from 'node-latex';
|
||||
import { Readable } from 'stream';
|
||||
import { existsSync } from 'fs';
|
||||
|
||||
const sanitize = (text?: string) => {
|
||||
if (!text) {
|
||||
return '';
|
||||
}
|
||||
return decode(text)
|
||||
.replace('&', '\\&')
|
||||
.replace('_', '\\_')
|
||||
.replace(/([^\\])\}/g, '$1\\}')
|
||||
.replace(/([^\\])\{/g, '$1\\{')
|
||||
.replace(/[^\\]\[/g, '\\[')
|
||||
.replace(/#/g, '\\#');
|
||||
};
|
||||
|
||||
const sanitizeUrl = (text?: string) => {
|
||||
if (!text) {
|
||||
return '';
|
||||
}
|
||||
return decode(text)
|
||||
.replace(/\//g, '\\/');
|
||||
};
|
||||
|
||||
const latexTypes = ['', '', 'section', 'subsection', 'paragraph', 'subparagraph'];
|
||||
|
||||
type Renderer = (depth: number) => {
|
||||
heading?: (text: string, depth: number) => string;
|
||||
code?: (input: string) => string;
|
||||
text?: (input: string) => string;
|
||||
paragraph?: (input: string) => string;
|
||||
list?: (input: string) => string;
|
||||
listitem?: (input: string) => string;
|
||||
link?: (href: string, text: string) => string;
|
||||
strong?: (text: string) => string;
|
||||
em?: (text: string) => string;
|
||||
codespan?: (code: string) => string;
|
||||
image?: (link: string) => string;
|
||||
};
|
||||
|
||||
const renderer = (outerDepth: number) => ({
|
||||
heading: (text: string, depth: number) => {
|
||||
return `\\${latexTypes[outerDepth + depth]}{${sanitize(text)}}\n\n`;
|
||||
},
|
||||
code: (input: string) => {
|
||||
return `
|
||||
\\begin{lstlisting}
|
||||
${input}
|
||||
\\end{lstlisting}
|
||||
`;
|
||||
},
|
||||
text: (input: string) => {
|
||||
return sanitize(input);
|
||||
},
|
||||
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) => {
|
||||
console.log('LINK TEXT', text, sanitize(href));
|
||||
if (!text || text === href) {
|
||||
return `\\url{${sanitize(href)}}`;
|
||||
}
|
||||
return `${sanitize(text)} (\\url{${sanitize(href)}})`;
|
||||
},
|
||||
strong: (text: string) => {
|
||||
return `\\textbf{${sanitize(text)}}`;
|
||||
},
|
||||
em: (text: string) => {
|
||||
return `\\textbf{${sanitize(text)}}`;
|
||||
},
|
||||
codespan: (code: string) => {
|
||||
return `\\texttt{${sanitize(code)}}`;
|
||||
},
|
||||
image: (link: string) => {
|
||||
if (!existsSync(link)) {
|
||||
return 'Online image not supported';
|
||||
}
|
||||
return `\\begin{figure}[h!]
|
||||
\\includegraphics[width=0.5\\textwidth]{${link}}
|
||||
\\centering
|
||||
\\end{figure}
|
||||
`;
|
||||
},
|
||||
});
|
||||
|
||||
const latexToPdf = (doc: string) =>
|
||||
new Promise<Buffer>((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
const input = new Readable();
|
||||
input.push(doc);
|
||||
input.push(null);
|
||||
const latexStream = latex(input);
|
||||
latexStream.on('data', (chunk) => {
|
||||
chunks.push(Buffer.from(chunk));
|
||||
});
|
||||
latexStream.on('finish', () => {
|
||||
const result = Buffer.concat(chunks);
|
||||
resolve(result);
|
||||
});
|
||||
latexStream.on('error', (err) => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
|
||||
export type { Renderer };
|
||||
export { sanitize, renderer, latexToPdf };
|
||||
9
packages/goodwrites-latex/tsconfig.json
Normal file
9
packages/goodwrites-latex/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist"
|
||||
},
|
||||
"include": [
|
||||
"./src"
|
||||
]
|
||||
}
|
||||
5
packages/goodwrites-viewer/.gitignore
vendored
Normal file
5
packages/goodwrites-viewer/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
|
||||
/node_modules/
|
||||
/*.logs
|
||||
/.yarn/
|
||||
/dist/
|
||||
37
packages/goodwrites-viewer/package.json
Normal file
37
packages/goodwrites-viewer/package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "@morten-olsen/goodwrites-viewer",
|
||||
"main": "./dist/index.js",
|
||||
"dependencies": {
|
||||
"@fontsource/merriweather": "^4.5.14",
|
||||
"@morten-olsen/goodwrites": "workspace:^",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.10",
|
||||
"babel-loader": "^9.1.0",
|
||||
"css-loader": "^6.7.2",
|
||||
"express": "^4.18.2",
|
||||
"file-loader": "^6.2.0",
|
||||
"html-webpack-plugin": "^5.5.0",
|
||||
"raw-loader": "^4.0.2",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-markdown": "^8.0.4",
|
||||
"react-pdf": "^6.2.0",
|
||||
"react-refresh": "^0.14.0",
|
||||
"style-loader": "^3.3.1",
|
||||
"styled-components": "^5.3.6",
|
||||
"webpack": "^5.75.0",
|
||||
"webpack-dev-middleware": "^6.0.1",
|
||||
"webpack-hot-middleware": "^2.25.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.14",
|
||||
"@types/html-webpack-plugin": "^3.2.6",
|
||||
"@types/loader-utils": "^2.0.3",
|
||||
"@types/react": "^18.0.26",
|
||||
"@types/react-dom": "^18.0.9",
|
||||
"@types/react-pdf": "^5.7.4",
|
||||
"@types/styled-components": "^5.1.26",
|
||||
"@types/webpack": "^5.28.0",
|
||||
"@types/webpack-dev-middleware": "^5.3.0",
|
||||
"@types/webpack-hot-middleware": "^2.25.6"
|
||||
}
|
||||
}
|
||||
1
packages/goodwrites-viewer/src/index.ts
Normal file
1
packages/goodwrites-viewer/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './webpack';
|
||||
62
packages/goodwrites-viewer/src/ui/app.tsx
Normal file
62
packages/goodwrites-viewer/src/ui/app.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import React, { FC } from 'react';
|
||||
import '@fontsource/merriweather/index.css';
|
||||
import styled, { createGlobalStyle } from 'styled-components';
|
||||
import { Block } from './components/block';
|
||||
import { useDoc } from './features/doc';
|
||||
import { Document, Page } from 'react-pdf/dist/esm/entry.webpack5';
|
||||
|
||||
const GlobalStyle = createGlobalStyle`
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html {
|
||||
font-size: 16px;
|
||||
}
|
||||
h1, h2, h3,h4, h5, h6 {
|
||||
margin: 0;
|
||||
}
|
||||
html, body, body > #__next {
|
||||
height: 100%;
|
||||
}
|
||||
body {
|
||||
background: #fff;
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
margin: auto;
|
||||
font-family: 'Merriweather', sans-serif;
|
||||
}
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: #3498db;
|
||||
}
|
||||
`;
|
||||
|
||||
const Cover = styled.img`
|
||||
max-width: 100%;
|
||||
`;
|
||||
|
||||
const Title = styled.h1`
|
||||
font-size: 3rem;
|
||||
line-height: 3.8rem;
|
||||
margin-bottom: 0px;
|
||||
`;
|
||||
|
||||
const App: FC = () => {
|
||||
const doc = useDoc();
|
||||
return (
|
||||
<div>
|
||||
<GlobalStyle />
|
||||
<Title>{doc.title}</Title>
|
||||
<Cover src={doc.cover} />
|
||||
<Document file={doc.pdf}>
|
||||
<Page pageNumber={1} renderTextLayer={false} />
|
||||
</Document>
|
||||
{doc.parts.map((part, index) => (
|
||||
<Block key={index} block={part} doc={doc} />
|
||||
))}
|
||||
<a href={(doc as any).pdf}>PDF {(doc as any).pdf}</a>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { App };
|
||||
105
packages/goodwrites-viewer/src/ui/components/block/index.tsx
Normal file
105
packages/goodwrites-viewer/src/ui/components/block/index.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import React, { FC } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import styled from 'styled-components';
|
||||
import { DocumentResult } from '@morten-olsen/goodwrites';
|
||||
|
||||
type Block = DocumentResult['parts'][0];
|
||||
|
||||
interface BlockProps {
|
||||
block: Block;
|
||||
doc: DocumentResult;
|
||||
}
|
||||
|
||||
const getBreadcrumb = (block: Block, doc: DocumentResult) => {
|
||||
const path = [...block.path];
|
||||
const elements: any[] = [
|
||||
{
|
||||
title: '#',
|
||||
},
|
||||
];
|
||||
|
||||
path.reduce((acc, part) => {
|
||||
const parent = acc[part as keyof typeof acc] as any;
|
||||
elements.push({
|
||||
...parent,
|
||||
title: parent.title ?? part,
|
||||
});
|
||||
return parent.content;
|
||||
}, doc.original.content as any);
|
||||
|
||||
return elements.map((s: any) => s.title).join(' > ');
|
||||
};
|
||||
|
||||
const Wrapper = styled.div`
|
||||
margin: 8px 0;
|
||||
border-radius: 5px;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const Note = styled.div`
|
||||
background-color: #efefef;
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const Content = styled.div<{
|
||||
level: number;
|
||||
border?: string;
|
||||
}>`
|
||||
letter-spacing: 0.5px;
|
||||
line-height: 2.1rem;
|
||||
border: solid 5px ${(props) => props.border || '#fff'};
|
||||
padding: 8px 32px;
|
||||
|
||||
p:first-of-type::first-letter {
|
||||
font-size: 6rem;
|
||||
float: left;
|
||||
padding: 1rem;
|
||||
margin: 0px 2rem;
|
||||
font-weight: 100;
|
||||
margin-left: 0rem;
|
||||
}
|
||||
|
||||
p + p::first-letter {
|
||||
margin-left: 1.8rem;
|
||||
}
|
||||
`;
|
||||
|
||||
const Breacrumb = styled.div`
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
font-family: monospace;
|
||||
text-transform: uppercase;
|
||||
font-weight: bold;
|
||||
opacity: 0.5;
|
||||
`;
|
||||
|
||||
const getBorderColor = (state: Block['state']) => {
|
||||
switch (state) {
|
||||
case 'first-draft':
|
||||
return '#3498db';
|
||||
case 'final-draft':
|
||||
return '#2ecc71';
|
||||
case 'revisions':
|
||||
return '#f1c40f';
|
||||
case 'placeholder':
|
||||
return '#e74c3c';
|
||||
}
|
||||
|
||||
return '#fff';
|
||||
};
|
||||
|
||||
const Block: FC<BlockProps> = ({ block, doc }) => {
|
||||
return (
|
||||
<Wrapper>
|
||||
<Breacrumb>{getBreadcrumb(block, doc)}</Breacrumb>
|
||||
{block.content && (
|
||||
<Content level={1} border={getBorderColor(block.state)}>
|
||||
<ReactMarkdown>{block.content}</ReactMarkdown>
|
||||
</Content>
|
||||
)}
|
||||
{block.notes && <Note>{block.notes}</Note>}
|
||||
</Wrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export { Block };
|
||||
49
packages/goodwrites-viewer/src/ui/features/doc/index.tsx
Normal file
49
packages/goodwrites-viewer/src/ui/features/doc/index.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
FC,
|
||||
useState,
|
||||
useEffect,
|
||||
ReactNode,
|
||||
} from 'react';
|
||||
import { DocumentResult } from '@morten-olsen/goodwrites';
|
||||
|
||||
type Document = DocumentResult;
|
||||
|
||||
declare var __DOC_LOCATION__: string;
|
||||
|
||||
type DocContextValue = {
|
||||
doc: Document;
|
||||
};
|
||||
|
||||
type DocProviderProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const DocContext = createContext<DocContextValue>(null as any);
|
||||
|
||||
const DocProvider: FC<DocProviderProps> = ({ children }) => {
|
||||
const [doc, setDoc] = useState<Document>(
|
||||
require(__DOC_LOCATION__) as Document
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const hot = (module as any).hot;
|
||||
|
||||
if (hot) {
|
||||
hot.accept('../../../../demo-article/index.yml', () => {
|
||||
const newDoc = require(__DOC_LOCATION__) as Document;
|
||||
setDoc(newDoc);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
return <DocContext.Provider value={{ doc }}>{children}</DocContext.Provider>;
|
||||
};
|
||||
|
||||
const useDoc = () => {
|
||||
const { doc } = useContext(DocContext);
|
||||
return doc;
|
||||
};
|
||||
|
||||
export { DocProvider, DocContext, useDoc };
|
||||
23
packages/goodwrites-viewer/src/ui/index.tsx
Normal file
23
packages/goodwrites-viewer/src/ui/index.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { App } from './app';
|
||||
import { DocProvider } from './features/doc';
|
||||
|
||||
const createRootElement = () => {
|
||||
const rootContainer = document.createElement('div');
|
||||
rootContainer.setAttribute('id', 'root');
|
||||
document.body.appendChild(rootContainer);
|
||||
return rootContainer;
|
||||
};
|
||||
|
||||
const rootElement = document.getElementById('root') || createRootElement();
|
||||
|
||||
const Root = () => {
|
||||
return (
|
||||
<DocProvider>
|
||||
<App />
|
||||
</DocProvider>
|
||||
);
|
||||
};
|
||||
|
||||
createRoot(rootElement).render(<Root />);
|
||||
82
packages/goodwrites-viewer/src/webpack.ts
Normal file
82
packages/goodwrites-viewer/src/webpack.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import express from 'express';
|
||||
import webpack, { Configuration } from 'webpack';
|
||||
import ReactRefreshWebpackPlugin from '@pmmmwh/react-refresh-webpack-plugin';
|
||||
import { join } from 'path';
|
||||
import HtmlWebpackPlugin from 'html-webpack-plugin';
|
||||
import hotMiddleware from 'webpack-hot-middleware';
|
||||
import devMiddleware from 'webpack-dev-middleware';
|
||||
|
||||
type WebpackOptions = {
|
||||
dev?: boolean;
|
||||
documentLocation: string;
|
||||
};
|
||||
|
||||
const createWebpack = ({
|
||||
documentLocation,
|
||||
dev = false,
|
||||
}: WebpackOptions): Configuration => ({
|
||||
mode: dev ? 'development' : 'production',
|
||||
context: join(__dirname, 'ui'),
|
||||
entry: ['webpack-hot-middleware/client', join(__dirname, 'ui', 'index.js')],
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: documentLocation,
|
||||
loader: require.resolve('@morten-olsen/goodwrites-webpack-loader'),
|
||||
},
|
||||
{
|
||||
test: /\.[jt]sx?$/,
|
||||
exclude: /node_modules/,
|
||||
use: [
|
||||
{
|
||||
loader: require.resolve('babel-loader'),
|
||||
options: {
|
||||
plugins: [require.resolve('react-refresh/babel')],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
test: /\.css$/i,
|
||||
use: ['style-loader', 'css-loader'],
|
||||
},
|
||||
{
|
||||
test: /\.(woff|woff2|eot|ttf|otf)$/i,
|
||||
type: 'asset/resource',
|
||||
},
|
||||
{
|
||||
test: /\.(png|jpe?g|gif)$/i,
|
||||
use: [
|
||||
{
|
||||
loader: 'file-loader',
|
||||
options: {
|
||||
name: '[name].[ext]',
|
||||
outputPath: 'images/',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
plugins: [
|
||||
new HtmlWebpackPlugin({}),
|
||||
new webpack.HotModuleReplacementPlugin(),
|
||||
new webpack.DefinePlugin({
|
||||
__DOC_LOCATION__: JSON.stringify(documentLocation),
|
||||
}),
|
||||
new ReactRefreshWebpackPlugin(),
|
||||
],
|
||||
});
|
||||
|
||||
const createServer = (options: WebpackOptions) => {
|
||||
const server = express();
|
||||
const webpackConfig = createWebpack(options);
|
||||
const compiler = webpack(webpackConfig);
|
||||
|
||||
server.use(devMiddleware(compiler));
|
||||
server.use(hotMiddleware(compiler));
|
||||
|
||||
return server;
|
||||
};
|
||||
|
||||
export { createWebpack, createServer };
|
||||
10
packages/goodwrites-viewer/tsconfig.json
Normal file
10
packages/goodwrites-viewer/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"jsx": "react"
|
||||
},
|
||||
"include": [
|
||||
"./src"
|
||||
]
|
||||
}
|
||||
5
packages/goodwrites-webpack-loader/.gitignore
vendored
Normal file
5
packages/goodwrites-webpack-loader/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
|
||||
/node_modules/
|
||||
/*.logs
|
||||
/.yarn/
|
||||
/dist/
|
||||
20
packages/goodwrites-webpack-loader/package.json
Normal file
20
packages/goodwrites-webpack-loader/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@morten-olsen/goodwrites-webpack-loader",
|
||||
"main": "./dist/index.js",
|
||||
"devDependencies": {
|
||||
"@types/fs-extra": "^9.0.13",
|
||||
"@types/marked": "^4.0.7",
|
||||
"@types/webpack": "^5.28.0",
|
||||
"loader-utils": "^3.2.1",
|
||||
"yaml": "^2.1.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@morten-olsen/goodwrites": "workspace:^",
|
||||
"@morten-olsen/goodwrites-latex": "workspace:^",
|
||||
"fs-extra": "^11.1.0",
|
||||
"marked": "^4.0.12",
|
||||
"node-latex": "^3.1.0",
|
||||
"slugify": "^1.6.5",
|
||||
"stream": "^0.0.2"
|
||||
}
|
||||
}
|
||||
3
packages/goodwrites-webpack-loader/src/index.ts
Normal file
3
packages/goodwrites-webpack-loader/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { webpackLoader } from './loader';
|
||||
|
||||
export default webpackLoader;
|
||||
88
packages/goodwrites-webpack-loader/src/loader.ts
Normal file
88
packages/goodwrites-webpack-loader/src/loader.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import * as webpack from 'webpack';
|
||||
import * as loaderUtils from 'loader-utils';
|
||||
import {
|
||||
replaceImages,
|
||||
Document,
|
||||
parseDocument,
|
||||
} from '@morten-olsen/goodwrites';
|
||||
import { join, dirname, basename } from 'path';
|
||||
import yaml from 'yaml';
|
||||
import { readFileSync } from 'fs-extra';
|
||||
import { toPdf } from '@morten-olsen/goodwrites-latex';
|
||||
import slugify from 'slugify';
|
||||
|
||||
type LoaderOptions = {
|
||||
publicPath?: string;
|
||||
outputPath?: string;
|
||||
};
|
||||
|
||||
function webpackLoader(
|
||||
this: webpack.LoaderContext<LoaderOptions>,
|
||||
contents: string = ''
|
||||
) {
|
||||
const options: LoaderOptions = this.getOptions();
|
||||
const publicPath =
|
||||
options.publicPath ?? '';
|
||||
const outputPath =
|
||||
options.outputPath ?? this._compilation?.outputOptions.path ?? '';
|
||||
|
||||
const callback = this.async();
|
||||
const source = this.resourcePath;
|
||||
|
||||
const run = async () => {
|
||||
const document: Document = yaml.parse(contents);
|
||||
const slug = slugify(document.title, { lower: true });
|
||||
const addFile = (fileLocation: string) => {
|
||||
const content = readFileSync(fileLocation);
|
||||
const filename = basename(fileLocation);
|
||||
const targetName = loaderUtils.interpolateName(
|
||||
this as any,
|
||||
`/goodwrite/${slug}/[hash]-${filename}`,
|
||||
{
|
||||
content,
|
||||
}
|
||||
);
|
||||
const targetLocation = targetName;
|
||||
this.emitFile(join(outputPath, targetLocation), content);
|
||||
this.addDependency(fileLocation);
|
||||
return join(publicPath, targetName);
|
||||
};
|
||||
const location = dirname(source);
|
||||
const parsed = await parseDocument({
|
||||
document,
|
||||
location,
|
||||
});
|
||||
const markdown = await replaceImages(parsed, {
|
||||
replaceImage: (image) => {
|
||||
return addFile(image);
|
||||
}
|
||||
});
|
||||
if (markdown.cover) {
|
||||
markdown.cover = await addFile(join(location, markdown.cover));
|
||||
}
|
||||
const pdf = await toPdf(parsed);
|
||||
const pdfName = loaderUtils.interpolateName(
|
||||
this as any,
|
||||
`/goodwrite/[hash]/${slug}.pdf`,
|
||||
{
|
||||
content: pdf,
|
||||
}
|
||||
);
|
||||
this.emitFile(join(outputPath, pdfName), pdf);
|
||||
return {
|
||||
...markdown,
|
||||
pdf: join(publicPath, pdfName),
|
||||
};
|
||||
};
|
||||
|
||||
run()
|
||||
.then((content) => {
|
||||
content.files.forEach((file) => (file ? this.addDependency(file) : null));
|
||||
callback(null, `module.exports=${JSON.stringify(content)}`);
|
||||
})
|
||||
.catch((error) => {
|
||||
callback(error);
|
||||
});
|
||||
}
|
||||
|
||||
export { webpackLoader };
|
||||
9
packages/goodwrites-webpack-loader/tsconfig.json
Normal file
9
packages/goodwrites-webpack-loader/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist"
|
||||
},
|
||||
"include": [
|
||||
"./src"
|
||||
]
|
||||
}
|
||||
5
packages/goodwrites/.gitignore
vendored
Normal file
5
packages/goodwrites/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
|
||||
/node_modules/
|
||||
/*.logs
|
||||
/.yarn/
|
||||
/dist/
|
||||
16
packages/goodwrites/package.json
Normal file
16
packages/goodwrites/package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "@morten-olsen/goodwrites",
|
||||
"packageManager": "yarn@3.1.0",
|
||||
"main": "./dist/index.js",
|
||||
"dependencies": {
|
||||
"@sinclair/typebox": "^0.25.10",
|
||||
"fs-extra": "^11.1.0",
|
||||
"reading-time": "^1.5.0",
|
||||
"remark": "^13",
|
||||
"remark-behead": "^2.3.3",
|
||||
"unist-util-visit": "^2.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/fs-extra": "^9.0.13"
|
||||
}
|
||||
}
|
||||
3
packages/goodwrites/src/global.d.ts
vendored
Normal file
3
packages/goodwrites/src/global.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
declare module 'remark-behead' {
|
||||
export default any;
|
||||
}
|
||||
2
packages/goodwrites/src/index.ts
Normal file
2
packages/goodwrites/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './types';
|
||||
export * from './parse';
|
||||
213
packages/goodwrites/src/parse/index.ts
Normal file
213
packages/goodwrites/src/parse/index.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import { Block, Document, LengthTarget } from '../types';
|
||||
import { resolve, dirname } from 'path';
|
||||
import { readFile } from 'fs-extra';
|
||||
import remark from 'remark';
|
||||
import remarkBehead from 'remark-behead';
|
||||
import visit from 'unist-util-visit';
|
||||
import readingTime from 'reading-time';
|
||||
|
||||
type Options = {
|
||||
depth?: number;
|
||||
document: Document;
|
||||
location: string;
|
||||
};
|
||||
|
||||
type ParseBlockOptions = {
|
||||
block: Block | Document;
|
||||
location: string;
|
||||
path: (string | number)[];
|
||||
depth: number;
|
||||
};
|
||||
|
||||
type ReplaceImageOptions = {
|
||||
input: string;
|
||||
cwd: string;
|
||||
fn?: (location: string) => string;
|
||||
};
|
||||
|
||||
type BlockResult = {
|
||||
content: string;
|
||||
type: 'title' | 'file' | 'meta';
|
||||
file?: string;
|
||||
stats?: ReturnType<typeof readingTime>;
|
||||
state?: Exclude<Block, string>['state'];
|
||||
path: (string | number)[];
|
||||
length?: LengthTarget;
|
||||
cwd: string;
|
||||
level?: number;
|
||||
notes?: string;
|
||||
};
|
||||
|
||||
const replaceImagesAction = async ({ input, cwd, fn }: ReplaceImageOptions) => {
|
||||
const walk = () => (tree: any) => {
|
||||
visit(tree, 'image', (node: any) => {
|
||||
if (!fn || !node.url.startsWith('./')) {
|
||||
return;
|
||||
}
|
||||
const resolvedLocation = fn(resolve('/', cwd, node.url));
|
||||
node.url = resolvedLocation;
|
||||
});
|
||||
};
|
||||
const markdown = String(
|
||||
await remark()
|
||||
.use(walk as any)
|
||||
.process(input)
|
||||
);
|
||||
|
||||
return markdown;
|
||||
};
|
||||
|
||||
const partsToArray = (parts: Block | Block[]) => {
|
||||
if (Array.isArray(parts)) {
|
||||
return parts;
|
||||
}
|
||||
return [parts];
|
||||
};
|
||||
|
||||
const parseBlock = async ({
|
||||
block,
|
||||
location,
|
||||
path,
|
||||
depth,
|
||||
}: ParseBlockOptions) => {
|
||||
const result: BlockResult[] = [];
|
||||
if (typeof block === 'string') {
|
||||
block = {
|
||||
file: block,
|
||||
};
|
||||
}
|
||||
|
||||
if ('file' in block) {
|
||||
const contentDepth = block.title ? depth + 1 : depth;
|
||||
const fileLocation = resolve(location, block.file);
|
||||
const fileContent = await readFile(fileLocation, 'utf-8');
|
||||
const markdown = String(
|
||||
await remark()
|
||||
.use(remarkBehead, { depth: contentDepth })
|
||||
.process(fileContent)
|
||||
);
|
||||
const title = block.title
|
||||
? ''.padStart(depth + 1, '#') + ' ' + block.title + '\n'
|
||||
: '';
|
||||
|
||||
result.push({
|
||||
content: `${title}${markdown}`,
|
||||
file: fileLocation,
|
||||
type: 'file',
|
||||
path,
|
||||
cwd: dirname(fileLocation),
|
||||
level: depth,
|
||||
length: block.lenght,
|
||||
state: block.state,
|
||||
stats: readingTime(markdown),
|
||||
notes: block.notes,
|
||||
});
|
||||
} else if (block.title) {
|
||||
result.push({
|
||||
content: ''.padStart(depth + 1, '#') + ' ' + block.title,
|
||||
type: 'title',
|
||||
level: depth,
|
||||
cwd: location,
|
||||
state: 'state' in block ? block.state : undefined,
|
||||
notes: 'notes' in block ? block.notes : undefined,
|
||||
path,
|
||||
});
|
||||
depth += 1;
|
||||
} else if ('notes' in block) {
|
||||
result.push({
|
||||
content: '',
|
||||
type: 'meta',
|
||||
level: depth,
|
||||
cwd: location,
|
||||
notes: 'notes' in block ? block.notes : undefined,
|
||||
state: block.state,
|
||||
path,
|
||||
});
|
||||
}
|
||||
if ('content' in block) {
|
||||
const sectionMarkdown = await Promise.all(
|
||||
partsToArray(block.content).map((s, index) =>
|
||||
parseBlock({
|
||||
block: s,
|
||||
path: [...path, index],
|
||||
location,
|
||||
depth,
|
||||
})
|
||||
)
|
||||
);
|
||||
result.push(...sectionMarkdown.flat());
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const parseDocument = async ({ document, location, depth = 1 }: Options) => {
|
||||
const parts = partsToArray(document.content);
|
||||
const parsedParts = await Promise.all(
|
||||
parts.map((block, index) =>
|
||||
parseBlock({
|
||||
block,
|
||||
location,
|
||||
depth,
|
||||
path: [index],
|
||||
})
|
||||
)
|
||||
);
|
||||
const partsList = parsedParts.flat();
|
||||
const content = partsList.map((p) => p.content).join('\n\n');
|
||||
const stats = readingTime(content);
|
||||
const files = partsList.map((p) => p.file).filter(Boolean);
|
||||
return {
|
||||
title: document.title,
|
||||
content,
|
||||
cover: document.cover,
|
||||
cwd: location,
|
||||
meta: document.meta,
|
||||
stats,
|
||||
files,
|
||||
original: document,
|
||||
parts: partsList,
|
||||
};
|
||||
};
|
||||
|
||||
type ToMarkdownOptions = {
|
||||
replaceImage?: (location: string) => string;
|
||||
};
|
||||
|
||||
const blockToMarkdown = async (
|
||||
block: BlockResult,
|
||||
{ replaceImage }: ToMarkdownOptions
|
||||
) => {
|
||||
if (replaceImage) {
|
||||
return replaceImagesAction({
|
||||
input: block.content,
|
||||
fn: replaceImage,
|
||||
cwd: block.cwd,
|
||||
});
|
||||
}
|
||||
return block.content;
|
||||
};
|
||||
|
||||
const replaceImages = async (
|
||||
result: DocumentResult,
|
||||
options: ToMarkdownOptions = {}
|
||||
) => {
|
||||
const blocks = await Promise.all(
|
||||
result.parts.map(async (block) => ({
|
||||
...block,
|
||||
content: await blockToMarkdown(block, options),
|
||||
}))
|
||||
);
|
||||
|
||||
return {
|
||||
...result,
|
||||
parts: blocks,
|
||||
content: blocks.map((b) => b.content).join('\n\n'),
|
||||
};
|
||||
};
|
||||
|
||||
type DocumentResult = ReturnType<typeof parseDocument> extends Promise<infer U>
|
||||
? U & { pdf?: string }
|
||||
: never;
|
||||
|
||||
export type { DocumentResult };
|
||||
export { parseDocument, replaceImages };
|
||||
37
packages/goodwrites/src/types/block.ts
Normal file
37
packages/goodwrites/src/types/block.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
type TimeTarget = {
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
};
|
||||
|
||||
type WordTarget = {
|
||||
words: number;
|
||||
};
|
||||
|
||||
type LengthTarget = TimeTarget | WordTarget;
|
||||
|
||||
type BaseBlock = {
|
||||
type?: string;
|
||||
title?: string;
|
||||
lenght?: LengthTarget;
|
||||
notes?: string;
|
||||
state?: 'placeholder' | 'first-draft' | 'revisions' | 'final-draft' | 'final';
|
||||
};
|
||||
|
||||
type FileBlock = BaseBlock & {
|
||||
file: string;
|
||||
};
|
||||
|
||||
type BlocksBlock = BaseBlock & {
|
||||
content: Block[];
|
||||
};
|
||||
|
||||
type Block = string | FileBlock | BlocksBlock;
|
||||
|
||||
export type {
|
||||
Block,
|
||||
BlocksBlock,
|
||||
FileBlock,
|
||||
LengthTarget,
|
||||
TimeTarget,
|
||||
WordTarget,
|
||||
};
|
||||
10
packages/goodwrites/src/types/document.ts
Normal file
10
packages/goodwrites/src/types/document.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Block } from './block';
|
||||
|
||||
type Document = {
|
||||
title: string;
|
||||
cover?: string;
|
||||
meta?: any;
|
||||
content: Block | Block[];
|
||||
};
|
||||
|
||||
export type { Document };
|
||||
2
packages/goodwrites/src/types/index.ts
Normal file
2
packages/goodwrites/src/types/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './block';
|
||||
export * from './document';
|
||||
9
packages/goodwrites/tsconfig.json
Normal file
9
packages/goodwrites/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist"
|
||||
},
|
||||
"include": [
|
||||
"./src"
|
||||
]
|
||||
}
|
||||
5
packages/markdown-loader/.gitignore
vendored
Normal file
5
packages/markdown-loader/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
|
||||
/node_modules/
|
||||
/*.logs
|
||||
/.yarn/
|
||||
/dist/
|
||||
11
packages/markdown-loader/package.json
Normal file
11
packages/markdown-loader/package.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@morten-olsen/markdown-loader",
|
||||
"main": "./dist/index.js",
|
||||
"devDependencies": {
|
||||
"remark": "^13"
|
||||
},
|
||||
"dependencies": {
|
||||
"front-matter": "^4.0.2",
|
||||
"fs-extra": "^11.1.0"
|
||||
}
|
||||
}
|
||||
3
packages/markdown-loader/src/index.ts
Normal file
3
packages/markdown-loader/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { webpackLoader } from './loader';
|
||||
|
||||
export default webpackLoader;
|
||||
27
packages/markdown-loader/src/loader.ts
Normal file
27
packages/markdown-loader/src/loader.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import * as webpack from 'webpack';
|
||||
import fm from 'front-matter';
|
||||
|
||||
function webpackLoader(
|
||||
this: webpack.LoaderContext<{}>,
|
||||
contents: string = ''
|
||||
) {
|
||||
const callback = this.async();
|
||||
|
||||
const run = async () => {
|
||||
const { attributes, body } = fm(contents);
|
||||
return {
|
||||
attributes,
|
||||
body,
|
||||
};
|
||||
};
|
||||
|
||||
run()
|
||||
.then((content) => {
|
||||
callback(null, `module.exports=${JSON.stringify(content)}`);
|
||||
})
|
||||
.catch((error) => {
|
||||
callback(error);
|
||||
});
|
||||
}
|
||||
|
||||
export { webpackLoader };
|
||||
9
packages/markdown-loader/tsconfig.json
Normal file
9
packages/markdown-loader/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist"
|
||||
},
|
||||
"include": [
|
||||
"./src"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user