Add ToC to blog posts

This commit is contained in:
Jade Ellis 2024-07-16 16:59:04 +01:00
parent 008bfa4d94
commit 58d3d01cbe
No known key found for this signature in database
GPG key ID: 8705A2A3EBF77BD2
7 changed files with 243 additions and 8 deletions

View file

@ -15,7 +15,7 @@ import rehypeKatexSvelte from 'rehype-katex-svelte';
// import github from "remark-github";
import rehypeSlug from 'rehype-slug';
// import rehypeToc from '@jsdevtools/rehype-toc';
import { createHighlighter } from "@bitmachina/highlighter";
import { parse, format } from "node:path";
@ -92,6 +92,110 @@ const httpHighlight = {
}
const hrefTemplate = (/** @type {string} */ permalink) => `#${permalink}`
// function customizeTOC(toc) {
// // console.log(toc)
// return {
// type: 'root',
// children: [{
// type: "element",
// // tagName: "svelte:component",
// // properties: { this: "{tocComponent}" },
// tagName: "div",
// properties: {},
// children: [toc],
// }]
// };
// }
function buildNestedHeadings(headings) {
let result = [];
let stack = [{ level: 0, children: result }];
for (let heading of headings) {
while (
stack.length > 1 &&
heading.level <= stack[stack.length - 1].level
) {
stack.pop();
}
let parent = stack[stack.length - 1];
let newHeading = {
...heading,
children: [],
level: heading.level,
};
parent.children.push(newHeading);
stack.push(newHeading);
}
return result;
}
import { visit } from 'unist-util-visit';
import { toString as mdast_tree_to_string } from 'mdast-util-to-string'
import GithubSlugger from 'github-slugger'
/**
* @param {{ prefix?: string; }} opts
*/
function add_toc_remark(opts) {
const slugs = new GithubSlugger()
const prefix = opts?.prefix || "";
return async function transformer(tree, vFile) {
slugs.reset()
vFile.data.flattenedHeadings = [];
visit(tree, 'heading', (node) => {
let title = mdast_tree_to_string(node);
vFile.data.flattenedHeadings.push({
level: node.depth,
title,
id: prefix + slugs.slug(title)
});
});
if (!vFile.data.fm) vFile.data.fm = {};
vFile.data.fm.flattenedHeadings = vFile.data.flattenedHeadings;
vFile.data.fm.headings = buildNestedHeadings(vFile.data.flattenedHeadings);
};
}
import { toString as hast_tree_to_string } from 'hast-util-to-string'
/**
* Determines whether the given node is an HTML element.
*/
function isHtmlElementNode(node) {
return typeof node === "object" &&
node.type === "element" &&
typeof node.tagName === "string" &&
"properties" in node &&
typeof node.properties === "object";
}
const HEADINGS = ["h1", "h2", "h3", "h4", "h5", "h6"]
/**
* Determines whether the given node is an HTML heading node, according to the specified options
*/
function isHeadingNode(node) {
return isHtmlElementNode(node) && HEADINGS.includes(node.tagName);
}
function add_toc_rehype(self, opts) {
return async function transformer(tree, vFile) {
// console.log(tree)
vFile.data.headings = [];
visit(tree, isHeadingNode, (node) => {
console.log(node)
vFile.data.headings.push({
level: node.depth,
title: hast_tree_to_string(node),
});
});
if (!vFile.data.fm) vFile.data.fm = {};
vFile.data.fm.headings = vFile.data.headings;
};
}
/**
* @type {import("mdsvex").MdsvexOptions}
*/
@ -104,6 +208,10 @@ const config = {
dashes: "oldschool",
},
// layout: {
// _: "./src/layout.svelte"
// },
highlight: {
// @ts-ignore
highlighter: await createHighlighter({ theme: "github-dark", langs: [httpHighlight] }),
@ -149,12 +257,13 @@ const config = {
// }],
// [remarkBibliography, { bibliography }],
// [remarkMermaid, {}]
[add_toc_remark, { prefix: "h-" }]
],
rehypePlugins: [
// @ts-ignore
rehypeKatexSvelte,
// @ts-ignore
rehypeSlug
[rehypeSlug, { prefix: "h-" }],
],
};

View file

@ -21,7 +21,10 @@
"@sveltejs/vite-plugin-svelte": "^3.1.1",
"@types/node": "^20.14.2",
"@types/sharedworker": "^0.0.115",
"github-slugger": "^2.0.0",
"glob": "^10.4.1",
"hast-util-to-string": "^3.0.0",
"mdast-util-to-string": "^4.0.0",
"mdsvex": "^0.11.2",
"rehype-katex-svelte": "1.2",
"rehype-slug": "^6.0.0",
@ -44,6 +47,7 @@
"tslib": "^2.6.3",
"typescript": "^5.4.5",
"unified": "^11.0.4",
"unist-util-visit": "^5.0.0",
"vite": "^5.3.1",
"vite-plugin-dynamic-import": "^1.5.0",
"vite-plugin-image-optimizer": "^1.1.8"

View file

@ -0,0 +1,78 @@
<script lang="ts">
import TocItem from "./TocItem.svelte";
let className = "toc";
type FlatHeading = { level: number; title: string };
export let headings: nestedListNode[];
// creates a `class` property, even
// though it is a reserved word
export { className as class };
export let listType = "ul";
let open = false;
/** @type {import('./$types').Snapshot<string>} */
export const snapshot = {
capture: () => open,
restore: (value: boolean) => (open = value),
};
// console.log(headings);
</script>
<aside class={className}>
<details bind:open>
<summary accesskey="c" title="(Alt + C)">Table of Contents</summary>
<div class="inner">
{#if headings?.length > 0}
<svelte:element
this={listType}
class="toc-level {'toc-level-' + headings[0].level}"
>
{#each headings as node}
<TocItem {node} {listType} />
{/each}
</svelte:element>
{/if}
</div>
</details>
</aside>
<style>
aside {
margin-block: calc(var(--spacing) / 4);
}
details {
/* margin: var(--spacing) 2px; */
margin: 0 2px;
border: 1px solid var(--surface-secondary-color);
background: var(--surface-color);
border-radius: var(--border-radius);
padding: 0.4em;
}
details summary {
cursor: zoom-in;
margin-inline-start: 10px;
user-select: none;
}
details[open] summary {
cursor: zoom-out;
}
summary {
font-weight: 500;
}
.inner {
padding: 0 10px;
opacity: 0.9;
margin-block-start: calc(var(--spacing) / 4);
margin-block-end: calc(var(--spacing) / 2);
margin-inline: calc(var(--spacing) / 2);
}
.inner :global(ul) {
margin: 0;
margin-inline-start: calc(var(--spacing));
padding: 0;
}
summary:focus {
outline: 0;
}
</style>

View file

@ -0,0 +1,16 @@
<script lang="ts">
export let node: nestedListNode;
export let listType = "ul"
</script>
<li class="toc-item {"toc-item-" + node.level}">
<a href={"#" + node.id} class="toc-link {"toc-link-" + node.level}">{node.title}</a>
{#if node.children.length > 0}
<svelte:element this={listType} class="toc-level {"toc-level-" + node.children[0].level}">
{#each node.children as nodes}
<svelte:self node={nodes} {listType} />
{/each}
</svelte:element>
{/if}
</li>

7
packages/website/src/lib/toc.d.ts vendored Normal file
View file

@ -0,0 +1,7 @@
type nestedListNode = {
title: string;
id: string;
level: number;
children: nestedListNode[];
};

View file

@ -4,6 +4,7 @@
import SvelteSeo from "svelte-seo";
export let data;
import { SITE_URL } from "$lib/metadata";
import Toc from "$lib/Toc.svelte";
// let GhReleasesDownload: Promise<any>;
// if (data.ghReleaseData) {
// GhReleasesDownload = import("$lib/GhReleasesDownload.svelte").then((m) => m.default)
@ -29,17 +30,25 @@
<article class="h-entry">
<h1 id="title" class="p-name">{data.post.title}</h1>
<aside>Published on <time class="dt-published" datetime={data.post.date}>{new Date(data.post.date).toLocaleDateString()}</time></aside>
<aside>
Published on <time class="dt-published" datetime={data.post.date}
>{new Date(data.post.date).toLocaleDateString()}</time
>
</aside>
<Toc headings={data.post.headings} />
<!-- {#await GhReleasesDownload}
{:then component}
<svelte:component this={component} releaseData={data.ghReleaseData} />
{/await} -->
<!-- <div class="e-content"> -->
<svelte:component this={data.component} />
<div class="e-content">
<svelte:component this={data.component} />
</div>
</article>
<style>
aside {font-size: .85em;}
</style>
aside {
font-size: 0.85em;
}
</style>

14
pnpm-lock.yaml generated
View file

@ -113,9 +113,18 @@ importers:
'@types/sharedworker':
specifier: ^0.0.115
version: 0.0.115
github-slugger:
specifier: ^2.0.0
version: 2.0.0
glob:
specifier: ^10.4.1
version: 10.4.1
hast-util-to-string:
specifier: ^3.0.0
version: 3.0.0
mdast-util-to-string:
specifier: ^4.0.0
version: 4.0.0
mdsvex:
specifier: ^0.11.2
version: 0.11.2(svelte@4.2.18)
@ -182,6 +191,9 @@ importers:
unified:
specifier: ^11.0.4
version: 11.0.4
unist-util-visit:
specifier: ^5.0.0
version: 5.0.0
vite:
specifier: ^5.3.1
version: 5.3.1(@types/node@20.14.2)(terser@5.31.1)
@ -934,7 +946,7 @@ packages:
'@codemirror/view': '>=6.0.0'
Notes@file:packages/website/Notes-1.0.0.tgz:
resolution: {integrity: sha512-vppkFly1DinPtUJSGlwbSWHXq7R9ozQoZHrWLjsnIDOvsbXYezdHm+KquuWVIEkEveDmg6SODp49j1Bsflnyzw==, tarball: file:packages/website/Notes-1.0.0.tgz}
resolution: {integrity: sha512-5ly2YBmx1v2j7S5QtOTzZbTY5WJv1/1JNKGXOle8VtgRwg1WvOohRqDSaySiypHDWOGebqLq9KbxIr/Y6VCUnw==, tarball: file:packages/website/Notes-1.0.0.tgz}
version: 1.0.0
acorn@8.12.0: