Export to site with Next.js and GitHub Actions

This commit is contained in:
Gabriel Arazas 2021-07-08 13:02:49 +08:00
parent 74fc53bb6a
commit fbe52394d5
17 changed files with 6170 additions and 0 deletions

34
.github/workflows/generate-site.yaml vendored Normal file
View File

@ -0,0 +1,34 @@
# TODO:
# - Setup the structure correctly for site generation
# - Build the site
# - Export the site to GitHub pages
name: Generate site to GitHub pages
on: [push]
jobs:
generate-site:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: cachix/install-nix-action@v13
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: workflow/nix-shell-action@v1
with:
packages: nodejs,coreutils
script: |
mkdir -p site/public
mv *.org structured/ site/public
cd site
npm install
npm run build
ls -la
- name: Deploy to GitHub Pages
if: success()
uses: crazy-max/ghaction-github-pages@v2
with:
jekyll: false
target_branch: gh-pages
build_dir: site/out
env:
GITHUB_TOKEN: ${{ secrets.PAGES_TOKEN }}

View File

@ -227,6 +227,17 @@ As a side effect, this mitigates against overwriting of generated assets from or
== Static site export
While the wiki is exclusively used with Emacs, there is an exported website with Next.js and link:https://github.com/rasendubi/uniorg/[uniorg] deployed using GitHub Actions (at link:./.github/workflows/[`./.github/workflows/`]).
The source code of the site is at link:./site/[`./site/`].
Here's the image summarizing the workflow.
image::assets/workflow.png[]
== Future tasks == Future tasks
This also means expect the following changes if you're watching this repo for some reason. This also means expect the following changes if you're watching this repo for some reason.

BIN
assets/workflow.kra Normal file

Binary file not shown.

BIN
assets/workflow.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

34
site/.gitignore vendored Normal file
View File

@ -0,0 +1,34 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env.local
.env.development.local
.env.test.local
.env.production.local
# vercel
.vercel

5641
site/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
site/package.json Normal file
View File

@ -0,0 +1,28 @@
{
"name": "org-braindump",
"version": "0.3.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build && next export",
"start": "next start"
},
"dependencies": {
"next": "10.0.5",
"orgast-util-visit-ids": "^0.3.0",
"react": "17.0.1",
"react-dom": "17.0.1",
"rehype-react": "^6.1.0",
"rehype-url-inspector": "^2.0.2",
"to-vfile": "^6.1.0",
"trough": "^1.0.5",
"unified": "^9.2.0",
"uniorg-extract-keywords": "^0.3.0",
"uniorg-parse": "^0.3.0",
"uniorg-rehype": "^0.3.0",
"uniorg-slug": "^0.3.0",
"vfile-find-down": "^5.0.1",
"vfile-rename": "^1.0.3",
"vfile-reporter": "^6.0.2"
}
}

View File

@ -0,0 +1,12 @@
import React from 'react';
import NextLink from 'next/link';
const MyLink = ({ href, ...props }) => {
return (
<NextLink href={href} passHref={true}>
<a href={href} {...props} />
</NextLink>
);
};
export default MyLink;

View File

@ -0,0 +1,24 @@
import React from 'react';
import unified from 'unified';
import rehype2react from 'rehype-react';
import Link from './Link';
// we use rehype-react to process hast and transform it to React
// component, which allows as replacing some of components with custom
// implementation. e.g., we can replace all <a> links to use
// `next/link`.
const processor = unified().use(rehype2react, {
createElement: React.createElement,
Fragment: React.Fragment,
components: {
a: Link,
},
});
const Rehype = ({ hast }) => {
return <>{processor.stringify(hast)}</>;
};
export default Rehype;

113
site/src/lib/api.js Normal file
View File

@ -0,0 +1,113 @@
import * as path from 'path';
import trough from 'trough';
import toVFile from 'to-vfile';
import findDown from 'vfile-find-down';
import rename from 'vfile-rename';
import report from 'vfile-reporter';
import orgToHtml from './orgToHtml';
import resolveLinks from './resolveLinks';
// We serve posts from "public" directory, so that we don't have to
// copy assets.
//
// If you change this directory, make sure you copy all assets
// (images, linked files) to the public directory, so that next.js
// serves them.
const pagesDirectory = path.join(process.cwd(), 'public');
const processor = trough()
.use(collectFiles)
.use(processPosts)
.use(resolveLinks)
.use(populateBacklinks);
function collectFiles(root) {
return new Promise((resolve, reject) => {
findDown.all(
(f, stats) => stats.isFile() && f.basename.endsWith('.org'),
root,
(err, files) => {
if (err) {
reject(err);
} else {
files.forEach((f) => {
const slug =
'/' + path.relative(root, f.path).replace(/\.org$/, '');
f.data.slug = slug;
});
resolve(files);
}
}
);
});
}
async function processPosts(files) {
return Promise.all(files.map(processPost));
async function processPost(file) {
try {
await toVFile.read(file, 'utf8');
} catch (e) {
console.error('Error reading file', file, e);
throw e;
}
rename(file, { path: file.data.slug });
await orgToHtml(file);
return file;
}
}
// Assign all collected backlinks to file. This function should be
// called after all pages have been processed---otherwise, it might
// miss backlinks.
function populateBacklinks(files) {
const backlinks = {};
files.forEach((file) => {
file.data.links = file.data.links || new Set();
file.data.backlinks = backlinks[file.data.slug] =
backlinks[file.data.slug] || new Set();
file.data.links.forEach((other) => {
backlinks[other] = backlinks[other] || new Set();
backlinks[other].add(file.data.slug);
});
});
}
const loadPosts = async () => {
const files = await new Promise((resolve, reject) =>
processor.run(pagesDirectory, (err, files) => {
console.error(report(err || files, { quiet: true }));
if (err) reject(err);
else resolve(files);
})
);
const posts = Object.fromEntries(files.map((f) => [f.data.slug, f]));
return posts;
};
const allPosts = async () => {
const posts = await loadPosts();
return posts;
};
export async function getAllPaths() {
const posts = await loadPosts();
return Object.keys(posts);
}
export async function getPostBySlug(slug) {
const posts = await allPosts();
const post = await posts[slug];
return post;
}
export async function getAllPosts() {
const posts = await allPosts();
return await Promise.all(Object.values(posts));
}

59
site/src/lib/orgToHtml.js Normal file
View File

@ -0,0 +1,59 @@
import unified from 'unified';
import orgParse from 'uniorg-parse';
import org2rehype from 'uniorg-rehype';
import extractKeywords from 'uniorg-extract-keywords';
import { uniorgSlug } from 'uniorg-slug';
import { visitIds } from 'orgast-util-visit-ids';
const processor = unified()
.use(orgParse)
.use(extractKeywords)
.use(uniorgSlug)
.use(extractIds)
.use(org2rehype)
.use(toJson);
export default async function orgToHtml(file) {
try {
return await processor.process(file);
} catch (e) {
console.error('failed to process file', file.path, e);
throw e;
}
}
function extractIds() {
return transformer;
function transformer(tree, file) {
const data = file.data || (file.data = {});
// ids is a map: id => #anchor
const ids = data.ids || (data.ids = {});
visitIds(tree, (id, node) => {
if (node.type === 'org-data') {
ids[id] = '';
} else if (node.type === 'headline') {
if (!node.data?.hProperties?.id) {
// The headline doesn't have an html id assigned. (Did you
// remove uniorg-slug?)
//
// Assign an html id property based on org id property.
node.data = node.data || {};
node.data.hProperties = node.data.hProperties || {};
node.data.hProperties.id = id;
}
ids[id] = '#' + node.data.hProperties.id;
}
});
}
}
/** A primitive compiler to return node as is without stringifying. */
function toJson() {
this.Compiler = (node) => {
return node;
};
}

View File

@ -0,0 +1,75 @@
import unified from 'unified';
import inspectUrls from 'rehype-url-inspector';
export default function resolveLinks(files) {
// map from id -> { path, url }
const idMap = {};
files.forEach((file) => {
Object.entries(file.data.ids).forEach(([id, anchor]) => {
idMap[id] = { path: file.path, anchor };
});
});
const processor = unified()
.use(fromJson)
.use(inspectUrls, { inspectEach: processUrl })
.use(toJson);
return Promise.all(files.map((file) => processor.process(file)));
/**
* Process each link to:
* 1. Resolve id links.
* 2. Convert relative file:// links to path used by
* blog. file://file.org -> /file.org
* 3. Collect all links to file.data.links, so they can be used later
* to calculate backlinks.
*/
function processUrl({ url: urlString, propertyName, node, file }) {
try {
// next/link does not handle relative urls properly. Use
// file.path (the slug of the file) to normalize link against.
let url = new URL(urlString, 'file://' + file.path);
// process id links
if (url.protocol === 'id:') {
const id = url.pathname;
const ref = idMap[id];
if (ref) {
url = new URL(`file://${ref.path}${ref.anchor}`);
} else {
console.warn(`${file.path}: Unresolved id link`, urlString);
}
// fallthrough. id links are re-processed as file links
}
if (url.protocol === 'file:') {
let href = url.pathname.replace(/\.org$/, '');
node.properties[propertyName] = href + url.hash;
file.data.links = file.data.links || [];
file.data.links.push(href);
}
} catch (e) {
// This can happen if org file contains an invalid string, that
// looks like URL string (e.g., "http://example.com:blah/"
// passes regexes, but fails to parse as URL).
console.warn(`${file.path}: Failed to process URL`, urlString, e);
// No re-throwing: the issue is not critical enough to stop
// processing. The document is still valid, it's just link that
// isn't.
}
}
}
function fromJson() {
this.Parser = (node, file) => {
return file.result || JSON.parse(node);
};
}
function toJson() {
this.Compiler = (node) => {
return node;
};
}

View File

@ -0,0 +1,60 @@
import { join } from 'path';
import Head from 'next/head';
import { getAllPaths, getPostBySlug } from '../lib/api';
import Link from '../components/Link';
import Rehype from '../components/Rehype';
const Note = ({ title, hast, backlinks }) => {
return (
<main>
<Head>
<title>{title}</title>
</Head>
<h1>{title}</h1>
<Rehype hast={hast} />
{!!backlinks.length && (
<section>
<h2>{'Backlinks'}</h2>
<ul>
{backlinks.map((b) => (
<li key={b.path}>
<Link href={b.path}>{b.title}</Link>
</li>
))}
</ul>
</section>
)}
</main>
);
};
export default Note;
export const getStaticPaths = async () => {
const paths = await getAllPaths();
// add '/' which is synonymous to '/index'
paths.push('/');
return {
paths,
fallback: false,
};
};
export const getStaticProps = async ({ params }) => {
const path = '/' + join(...(params.slug || ['index']));
const post = await getPostBySlug(path);
const data = post.data;
const backlinks = await Promise.all([...data.backlinks].map(getPostBySlug));
return {
props: {
title: data.title || post.basename,
hast: post.result,
backlinks: backlinks.map((b) => ({
path: b.path,
title: b.data.title || b.basename,
})),
},
};
};

7
site/src/pages/_app.js Normal file
View File

@ -0,0 +1,7 @@
import '../styles/globals.css';
function MyApp({ Component, pageProps }) {
return <Component {...pageProps} />;
}
export default MyApp;

View File

@ -0,0 +1,33 @@
import Head from 'next/head';
import { getAllPosts } from '../lib/api';
import Link from '../components/Link';
const Archive = ({ posts }) => {
return (
<main>
<Head>
<title>{'Archive'}</title>
</Head>
<h1>{'Archive'}</h1>
<ul>
{posts.map((p) => (
<li key={p.path}>
<Link href={p.path}>{p.title}</Link>
</li>
))}
</ul>
</main>
);
};
export default Archive;
export const getStaticProps = async () => {
const allPosts = await getAllPosts();
const posts = allPosts
.map((p) => ({ title: p.data.title || p.basename, path: p.path }))
.sort((a, b) => {
return a.title.toLowerCase() < b.title.toLowerCase() ? -1 : 1;
});
return { props: { posts } };
};

View File

@ -0,0 +1,34 @@
html,
body {
padding: 0;
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
line-height: 1.5;
word-wrap: break-word;
overflow-wrap: break-word;
}
* {
box-sizing: border-box;
}
#__next {
margin: 16px;
}
main {
max-width: 800px;
margin: auto;
}
pre {
background-color: #eee;
padding: 4px;
overflow: auto;
}
code {
background-color: #eee;
}

5
site/vercel.json Normal file
View File

@ -0,0 +1,5 @@
{
"github": {
"silent": true
}
}