Merge pull request #167 from life-itself/152-rhs-toc
[Site/MDX]: Right hand side table of contents.
This commit is contained in:
commit
6631b23179
|
|
@ -192,7 +192,7 @@ Understand the deeper theoretical concepts behind the technical and economic cla
|
|||
* [Predatory inclusion](../concepts/predatory-inclusion.md)
|
||||
* [Enclosure](../concepts/enclosure.md)
|
||||
|
||||
**Meta**
|
||||
#### Meta
|
||||
|
||||
* [Value](../concepts/value.md)
|
||||
* [Risk](../concepts/risk.md)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
|
||||
export const Heading = ({ level, observer }) => (props) => {
|
||||
useEffect(() => {
|
||||
/* start observing heading's intersection with the bounding box
|
||||
* set by observer's `rootMargin` */
|
||||
if (!observer) {
|
||||
return
|
||||
}
|
||||
observer.observe(document.getElementById(props.id));
|
||||
});
|
||||
|
||||
return React.createElement(`h${level}`, {
|
||||
...props,
|
||||
className: "c-heading scroll-mt-16 cursor-pointer"
|
||||
})
|
||||
}
|
||||
|
|
@ -1,7 +1,10 @@
|
|||
import Head from 'next/head'
|
||||
import Nav from './Nav'
|
||||
|
||||
import siteConfig from '../config/siteConfig'
|
||||
import navLinks from '../config/navLinks.js'
|
||||
import navLinks from '../config/navLinks'
|
||||
import Nav from './Nav'
|
||||
// import Sidebar from './Sidebar'
|
||||
|
||||
|
||||
export default function Layout({ children }) {
|
||||
return (
|
||||
|
|
@ -18,11 +21,11 @@ export default function Layout({ children }) {
|
|||
{children}
|
||||
</main>
|
||||
<footer className="w-full h-24 mt-16">
|
||||
<div className="max-w-7xl mx-auto py-12 px-4 overflow-hidden sm:px-6 lg:px-8">
|
||||
<div className="max-w-7xl mx-auto py-12 px-4 sm:px-6 md:px-8 overflow-hidden">
|
||||
<nav className="-mx-5 -my-2 flex flex-wrap justify-center" aria-label="Footer">
|
||||
{navLinks.map((item) => (
|
||||
<div key={item.name} className="px-5 py-2">
|
||||
<a href={item.href} className="text-base text-gray-500 hover:text-gray-900">
|
||||
<a href={item.href} className="text-base text-gray-400 hover:text-gray-500">
|
||||
{item.name}
|
||||
</a>
|
||||
</div>
|
||||
|
|
@ -36,21 +39,25 @@ export default function Layout({ children }) {
|
|||
</a>
|
||||
))}
|
||||
</div>
|
||||
<p className="flex items-center justify-center mt-8">
|
||||
Created by
|
||||
<a
|
||||
href={siteConfig.authorUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<img src={siteConfig.authorLogo} alt={siteConfig.author} width="20" height="20" className="mx-2 h-6 inline-block" />
|
||||
{siteConfig.author}
|
||||
{' '}
|
||||
<div className="flex flex-col items-center mt-8 text-gray-400">
|
||||
<p>
|
||||
Created by
|
||||
<a
|
||||
href={siteConfig.authorUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<img src={siteConfig.authorLogo} alt={siteConfig.author} width="20" height="20" className="mx-2 h-6 inline-block" />
|
||||
{siteConfig.author}
|
||||
{' '}
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
Licensed under a CC-By 4.0 International License
|
||||
</a>
|
||||
</p>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,28 +1,15 @@
|
|||
import Head from 'next/head'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { NextSeo } from 'next-seo'
|
||||
import siteConfig from "../config/siteConfig"
|
||||
import LiteYouTubeEmbed from "react-lite-youtube-embed"
|
||||
import { YOUTUBE_REGEX } from "../lib/constants"
|
||||
import { NextSeo } from "next-seo";
|
||||
import LiteYouTubeEmbed from "react-lite-youtube-embed";
|
||||
|
||||
const Anchor = dynamic(() => import('./Anchor').then(module => module.Anchor), {
|
||||
ssr: false
|
||||
})
|
||||
import { YOUTUBE_REGEX } from "../lib/constants";
|
||||
import siteConfig from "../config/siteConfig";
|
||||
import MdxContent from "./MdxContent";
|
||||
|
||||
const Paragraph = dynamic(() => import("./Paragraph").then(mod => mod.Paragraph))
|
||||
export default function MdxPage({ body, meta }) {
|
||||
const { title, description, date, keywords, youtube, podcast, image, _raw } =
|
||||
meta;
|
||||
|
||||
const components = {
|
||||
Head,
|
||||
p: Paragraph,
|
||||
a: Anchor
|
||||
}
|
||||
|
||||
export default function MdxPage({ children }) {
|
||||
const { Component, frontmatter: {
|
||||
title, description, date, keywords, youtube, podcast, image, _raw
|
||||
}} = children
|
||||
|
||||
let youtubeThumnbnail
|
||||
let youtubeThumnbnail;
|
||||
|
||||
const youtubeId =
|
||||
youtube && YOUTUBE_REGEX.test(youtube) && youtube.split(/^|=|\//).pop();
|
||||
|
|
@ -47,12 +34,14 @@ export default function MdxPage({ children }) {
|
|||
const SeoTitle = title ?? titleFromUrl;
|
||||
const imageUrl = image
|
||||
? siteConfig.url + image
|
||||
: youtubeThumnbnail ? youtubeThumnbnail : null
|
||||
|
||||
: youtubeThumnbnail
|
||||
? youtubeThumnbnail
|
||||
: null;
|
||||
|
||||
// enable editing content only for claims, concepts, and guide for now
|
||||
const editUrl = ['claims', 'concepts', 'guide'].includes(_raw.sourceFileDir)
|
||||
? siteConfig.repoRoot + siteConfig.repoEditPath + _raw.sourceFilePath
|
||||
: null
|
||||
const editUrl = ["claims", "concepts", "guide"].includes(_raw.sourceFileDir)
|
||||
? siteConfig.repoRoot + siteConfig.repoEditPath + _raw.sourceFilePath
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -66,72 +55,89 @@ export default function MdxPage({ children }) {
|
|||
url: `${siteConfig.url}/${_raw.flattenedPath}`,
|
||||
type: "article",
|
||||
article: {
|
||||
tags: keywords ? keywords.split(",") : []
|
||||
tags: keywords ? keywords.split(",") : [],
|
||||
},
|
||||
images: imageUrl
|
||||
? ([
|
||||
? [
|
||||
{
|
||||
url: imageUrl,
|
||||
width: 1200,
|
||||
height: 627,
|
||||
alt: title,
|
||||
type: "image/png"
|
||||
type: "image/png",
|
||||
},
|
||||
])
|
||||
]
|
||||
: siteConfig.nextSeo.openGraph.images,
|
||||
}}
|
||||
additionalMetaTags={[
|
||||
{ name: "keywords", content: keywords ? keywords : "" }
|
||||
{ name: "keywords", content: keywords ? keywords : "" },
|
||||
]}
|
||||
/>
|
||||
<article className="prose dark:prose-invert prose-a:break-all mx-auto p-6">
|
||||
<header>
|
||||
<div className="mb-6">
|
||||
{title && <h1 className="mb-0">{title}</h1>}
|
||||
{date && (
|
||||
<p className="text-gray-900 dark:text-gray-500 text-sm pl-2">
|
||||
on {date}
|
||||
</p>
|
||||
)}
|
||||
{description && (
|
||||
<p className="">{description}</p>
|
||||
)}
|
||||
{youtubeId && (
|
||||
<LiteYouTubeEmbed id={youtubeId} />
|
||||
)}
|
||||
{podcast && (
|
||||
<div className="pt-4">
|
||||
<ul className="list-disc">
|
||||
<li>
|
||||
<a className="flex items-center" target="_blank" rel="noopener" href={podcast}>
|
||||
<div className="w-4 mr-2">
|
||||
<PodcastIcon />
|
||||
</div>
|
||||
<p className="m-0">Listen to this podcast</p>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div className="max-w-7xl mx-auto px-2 sm:px-6 md:px-8">
|
||||
<article className="prose dark:prose-invert prose-a:break-all mx-auto lg:mr-[20rem] p-6">
|
||||
<header>
|
||||
<div className="mb-6">
|
||||
{title && <h1 className="mb-0">{title}</h1>}
|
||||
{date && (
|
||||
<p className="text-gray-900 dark:text-gray-500 text-sm pl-2">
|
||||
on {date}
|
||||
</p>
|
||||
)}
|
||||
{description && <p className="">{description}</p>}
|
||||
{youtubeId && <LiteYouTubeEmbed id={youtubeId} />}
|
||||
{podcast && (
|
||||
<div className="pt-4">
|
||||
<ul className="list-disc">
|
||||
<li>
|
||||
<a
|
||||
className="flex items-center"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
href={podcast}
|
||||
>
|
||||
<div className="w-4 mr-2">
|
||||
<PodcastIcon />
|
||||
</div>
|
||||
<p className="m-0">Listen to this podcast</p>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
<main className="my-6">
|
||||
<MdxContent body={body} />
|
||||
{editUrl && (
|
||||
<div className="mt-12 mb-6">
|
||||
<a
|
||||
className="flex no-underline font-semibold text-yellow-li"
|
||||
href={editUrl}
|
||||
target="_blank"
|
||||
>
|
||||
Edit this page
|
||||
<span className="mx-1">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<div className="my-6">
|
||||
<Component components={components} />
|
||||
</div>
|
||||
{editUrl && (
|
||||
<div className='mt-12 mb-6'>
|
||||
<a className="flex no-underline font-semibold text-yellow-li" href={editUrl} target="_blank">
|
||||
Edit this page
|
||||
<span className="mx-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</span>
|
||||
</a>
|
||||
</div>)}
|
||||
</main>
|
||||
</article>
|
||||
</main>
|
||||
</article>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
import React from "react";
|
||||
import Head from "next/head";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useMDXComponent } from "next-contentlayer/hooks";
|
||||
|
||||
import { Heading } from "./Heading";
|
||||
import useHeadingsObserver from "../hooks/useHeadingsObserver";
|
||||
|
||||
const Anchor = dynamic(
|
||||
() => import("./Anchor").then((module) => module.Anchor)
|
||||
// {
|
||||
// ssr: false,
|
||||
// }
|
||||
);
|
||||
|
||||
const Paragraph = dynamic(() =>
|
||||
import("./Paragraph").then((module) => module.Paragraph)
|
||||
);
|
||||
|
||||
const MdxContent = ({ body }) => {
|
||||
const observer = useHeadingsObserver();
|
||||
|
||||
const customComponents = {
|
||||
Head,
|
||||
p: Paragraph,
|
||||
a: Anchor,
|
||||
h1: Heading({ level: 1, observer }),
|
||||
h2: Heading({ level: 2, observer }),
|
||||
h3: Heading({ level: 3, observer }),
|
||||
h4: Heading({ level: 4, observer }),
|
||||
h5: Heading({ level: 5, observer }),
|
||||
h6: Heading({ level: 6, observer }),
|
||||
};
|
||||
const Component = useMDXComponent(body.code);
|
||||
|
||||
return <Component components={customComponents} />;
|
||||
};
|
||||
|
||||
export default MdxContent;
|
||||
|
|
@ -4,6 +4,7 @@ import remarkGfm from 'remark-gfm'
|
|||
import rehypeSlug from 'rehype-slug'
|
||||
import rehypeAutolinkHeadings from 'rehype-autolink-headings'
|
||||
import wikiLinkPlugin from "remark-wiki-link-plus"
|
||||
import rehypeToc from "@jsdevtools/rehype-toc"
|
||||
|
||||
const isValidDate = dateObject => new Date(dateObject)
|
||||
.toString() !== 'Invalid Date';
|
||||
|
|
@ -58,8 +59,12 @@ export default makeSource({
|
|||
mdx: {
|
||||
remarkPlugins: [
|
||||
remarkGfm,
|
||||
[wikiLinkPlugin, { markdownFolder: 'content' }]
|
||||
[ wikiLinkPlugin, { markdownFolder: 'content' } ]
|
||||
],
|
||||
rehypePlugins: [rehypeSlug, rehypeAutolinkHeadings]
|
||||
rehypePlugins: [
|
||||
rehypeSlug,
|
||||
[ rehypeAutolinkHeadings, { behavior: 'wrap' } ],
|
||||
[ rehypeToc, { position: 'afterend' } ]
|
||||
]
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
import { useState, useEffect } from "react";
|
||||
|
||||
/* Creates an Intersection Observer to keep track of headings intersecting
|
||||
* a section of the viewport defined by the rootMargin */
|
||||
const getIntersectionObserver = (callback) => {
|
||||
return;
|
||||
};
|
||||
|
||||
const useHeadingsObserver = () => {
|
||||
const [activeHeading, setActiveHeading] = useState("");
|
||||
const [observer, setObserver] = useState(null);
|
||||
|
||||
/* Runs only after the first render, in order to preserve the observer
|
||||
* between component rerenderings (e.g. after activeHeading change). */
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
// entries.forEach((entry) => {
|
||||
// if (entry.isIntersecting) {
|
||||
// setActiveHeading(entry.target.id);
|
||||
// }
|
||||
// });
|
||||
const firstIntersectingHeading = entries.find(
|
||||
(entry) => entry.isIntersecting
|
||||
);
|
||||
if (firstIntersectingHeading) {
|
||||
setActiveHeading(firstIntersectingHeading.target.id);
|
||||
}
|
||||
},
|
||||
{
|
||||
root: null,
|
||||
threshold: 0.55,
|
||||
rootMargin: "-65px 0% -85% 0%", // 65px is a navbar height
|
||||
}
|
||||
);
|
||||
|
||||
setObserver(observer);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeHeading) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tocLink = document.querySelector(
|
||||
`.toc-link[href="#${activeHeading}"]`
|
||||
);
|
||||
tocLink.classList.add("active");
|
||||
|
||||
return () => {
|
||||
tocLink.classList.remove("active");
|
||||
};
|
||||
}, [activeHeading]);
|
||||
|
||||
return observer;
|
||||
};
|
||||
|
||||
export default useHeadingsObserver;
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -31,6 +31,7 @@
|
|||
"postcss": "^8.3.5",
|
||||
"prettier": "^2.6.2",
|
||||
"rehype-autolink-headings": "^6.1.1",
|
||||
"rehype-toc": "^3.0.2",
|
||||
"rehype-slug": "^5.0.1",
|
||||
"remark-gfm": "^3.0.0",
|
||||
"remark-parse": "^10.0.1",
|
||||
|
|
|
|||
|
|
@ -1,41 +1,30 @@
|
|||
import MdxPage from '../components/MDX';
|
||||
import { allOtherPages } from 'contentlayer/generated';
|
||||
import { useMDXComponent } from 'next-contentlayer/hooks';
|
||||
import { allOtherPages } from "contentlayer/generated";
|
||||
|
||||
import MdxPage from "../components/MDX";
|
||||
import siteConfig from "../config/siteConfig";
|
||||
|
||||
export default function Page({ body, ...rest }) {
|
||||
const Component = useMDXComponent(body.code);
|
||||
const children = {
|
||||
Component,
|
||||
frontmatter: {
|
||||
...rest
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<MdxPage children={children} />
|
||||
);
|
||||
export default function Page({ body, ...meta }) {
|
||||
return <MdxPage body={body} meta={meta} />;
|
||||
}
|
||||
|
||||
export const getStaticProps = async ({ params }) => {
|
||||
// All pages ending with .md in the /data folder are made available in allOtherPages
|
||||
// Based on the specified slug, the correct page is selected
|
||||
const urlPath = params.slug.join('/')
|
||||
const page = allOtherPages.find(p => p._raw.flattenedPath === urlPath)
|
||||
return { props: page }
|
||||
}
|
||||
const urlPath = params.slug.join("/");
|
||||
const page = allOtherPages.find((p) => p._raw.flattenedPath === urlPath);
|
||||
return { props: page };
|
||||
};
|
||||
|
||||
export const getStaticPaths = async () => {
|
||||
const paths = allOtherPages.map((page) => {
|
||||
// demo => [demo]
|
||||
// abc/demo => [abc,demo]
|
||||
const parts = page._raw.flattenedPath.split('/')
|
||||
return { params: { slug: parts } }
|
||||
})
|
||||
const parts = page._raw.flattenedPath.split("/");
|
||||
return { params: { slug: parts } };
|
||||
});
|
||||
|
||||
return {
|
||||
paths,
|
||||
fallback: false,
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -11,11 +11,18 @@
|
|||
}
|
||||
|
||||
/* OTHERS */
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
.c-heading a {
|
||||
@apply no-underline !important;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
@apply text-yellow-li !important;
|
||||
}
|
||||
|
||||
/* bg-neutral-800
|
||||
@apply bg-slate-800
|
||||
*/
|
||||
|
|
@ -38,3 +45,64 @@ body {
|
|||
width: 10rem;
|
||||
background: linear-gradient(to right, rgba(255, 255, 255, 0), rgba(255, 255, 255, 1) 100%);
|
||||
}
|
||||
|
||||
/* rehype-toc classes */
|
||||
/* nav element */
|
||||
.toc {
|
||||
@apply
|
||||
hidden
|
||||
lg:block
|
||||
w-[20rem]
|
||||
my-12
|
||||
pt-12
|
||||
px-8
|
||||
fixed
|
||||
top-16
|
||||
bottom-0
|
||||
right-[max(2rem,calc(50%-40rem+2rem))]
|
||||
overflow-y-auto
|
||||
/* border-l */
|
||||
/* border-slate-800 */
|
||||
}
|
||||
|
||||
/* toc title */
|
||||
.toc::before {
|
||||
position: absolute;
|
||||
content: "On this page";
|
||||
@apply text-white text-xl font-semibold top-1
|
||||
}
|
||||
|
||||
/* list (ol) element */
|
||||
.toc-level {
|
||||
@apply list-none p-0;
|
||||
}
|
||||
|
||||
.toc-level:not(.toc-level-1) {
|
||||
@apply pl-2
|
||||
}
|
||||
|
||||
/* list item (li) element */
|
||||
.toc-item {
|
||||
@apply leading-3;
|
||||
}
|
||||
|
||||
.toc-item-h1 {
|
||||
@apply p-0;
|
||||
}
|
||||
|
||||
.toc-link {
|
||||
@apply transition-colors;
|
||||
}
|
||||
|
||||
/* link (a) element */
|
||||
.toc-item .toc-link {
|
||||
@apply text-sm text-slate-400 no-underline break-normal
|
||||
}
|
||||
|
||||
.toc-item .toc-link:not(.active) {
|
||||
@apply hover:text-white
|
||||
}
|
||||
|
||||
.toc-link.active {
|
||||
@apply text-yellow-li
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
import { unified } from 'unified'
|
||||
import remarkParse from 'remark-parse'
|
||||
import find from 'unist-util-find'
|
||||
import { toString } from 'mdast-util-to-string'
|
||||
|
||||
|
||||
// get first paragraph found in the document
|
||||
const documentExtract = (md) => {
|
||||
const mdast = unified().use(remarkParse).parse(md);
|
||||
let paragraph = find(mdast, (node) => node.type === "paragraph");
|
||||
let paragraph = mdast.children.find((node) => node.type === "paragraph");
|
||||
return toString(paragraph);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue