Skip to content

Commit ef63c19

Browse files
vcarlclaude
andauthored
Add collapse behavior to nested lists in TMiR table of contents (#355)
* Add collapsible nested items to transcript table of contents Implements individually collapsible list items for nested sections in transcript TOCs using native HTML details/summary elements. Each parent item with children can now be collapsed independently without JavaScript. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * Change summary element to display: block Updates TOC summary elements to use block display instead of inline for better layout control while keeping the arrow inline. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * Improve rendering of table of contents for TMiR --------- Co-authored-by: Claude <[email protected]>
1 parent dcf0a10 commit ef63c19

File tree

6 files changed

+112
-14
lines changed

6 files changed

+112
-14
lines changed

src/components/Layout/LayoutStyles.js

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -57,15 +57,9 @@ const standardLayout = css`
5757
}
5858
5959
details {
60-
margin-bottom: 1rem;
61-
6260
& summary {
6361
cursor: pointer;
6462
}
65-
66-
&:last-of-type {
67-
margin-bottom: 2rem;
68-
}
6963
}
7064
7165
blockquote {

src/components/Layout/MainStyles.js

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -62,14 +62,11 @@ html body {
6262
}
6363
6464
ul, ol {
65-
padding-left: 3rem;
66-
65+
padding-left: 1.5rem;
66+
padding-top: 0.5rem;
6767
li {
6868
margin-bottom: 0.5rem;
6969
}
70-
li:first-of-type {
71-
margin-top: 0.5rem;
72-
}
7370
}
7471
7572
h1, h2, h3, h4, h5, h6 {
@@ -136,5 +133,9 @@ html body {
136133
text-decoration: underline;
137134
}
138135
}
136+
137+
summary::before {
138+
margin-right: 0.5rem;
139+
}
139140
}
140141
`;

src/components/Layout/MarkdownStyles.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,37 @@ export const MarkdownStyles = createGlobalStyle`
2929
text-decoration: underline;
3030
}
3131
}
32+
code {
33+
font-size: 80%;
34+
}
3235
a, code, strong {
3336
white-space: pre-wrap;
3437
word-break: break-word;
3538
word-wrap: break-word;
3639
}
40+
li > details {
41+
display: block;
42+
}
43+
summary {
44+
cursor: pointer;
45+
user-select: none;
46+
list-style: none;
47+
display: block;
48+
&::marker,
49+
&::-webkit-details-marker {
50+
display: none;
51+
}
52+
&::before {
53+
content: '▶ ';
54+
display: inline;
55+
font-size: 0.75em;
56+
transition: transform 0.15s ease;
57+
display: inline-block;
58+
width: 1em;
59+
}
60+
}
61+
details[open] > summary::before {
62+
transform: rotate(90deg);
63+
}
3764
}
3865
`;

src/helpers/rehypeWrapFirstList.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { visit } from "unist-util-visit";
2+
import type { Root, Element, ElementContent } from "hast";
3+
4+
/**
5+
* Rehype plugin that makes nested list items individually collapsible.
6+
* For each <li> that contains a nested <ul>, wraps the content in
7+
* <details><summary> to enable collapse/expand functionality.
8+
*/
9+
export default function rehypeWrapFirstList() {
10+
return (tree: Root) => {
11+
visit(tree, "element", (node) => {
12+
// Only process <li> elements
13+
if (node.tagName !== "li") {
14+
return;
15+
}
16+
17+
// Check if this <li> has a nested <ul> child
18+
const nestedUlIndex = node.children.findIndex(
19+
(child): child is Element =>
20+
child.type === "element" && child.tagName === "ul",
21+
);
22+
23+
// If no nested <ul>, nothing to do
24+
if (nestedUlIndex === -1) {
25+
return;
26+
}
27+
28+
// Split children into summary content (before ul) and nested ul
29+
const summaryContent = node.children.slice(0, nestedUlIndex);
30+
const nestedUl = node.children[nestedUlIndex] as Element;
31+
32+
// Only wrap if there's content to put in the summary
33+
if (summaryContent.length === 0) {
34+
return;
35+
}
36+
37+
// Create the summary element with the content before the nested ul
38+
const summary: Element = {
39+
type: "element",
40+
tagName: "summary",
41+
properties: {},
42+
children: summaryContent as ElementContent[],
43+
};
44+
45+
// Create the details element
46+
const details: Element = {
47+
type: "element",
48+
tagName: "details",
49+
properties: { open: true }, // Open by default
50+
children: [summary, nestedUl],
51+
};
52+
53+
// Replace the <li>'s children with just the details element
54+
node.children = [details];
55+
});
56+
};
57+
}

src/helpers/retrieveMdPages.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import rehypeStringify from "rehype-stringify";
1010
import rehypeSlug from "rehype-slug";
1111
import remarkHeadings, { hasHeadingsData } from "@vcarl/remark-headings";
1212
import { toString } from "mdast-util-to-string";
13+
import rehypeWrapFirstList from "./rehypeWrapFirstList";
1314

1415
const loadMd = async (path: string) => {
1516
const fullPath = join(process.cwd(), `${path}.md`);
@@ -39,8 +40,26 @@ const remarkHtmlProcessor = unified()
3940
.use(rehypeSlug)
4041
.use(rehypeStringify, { allowDangerousHtml: true });
4142

42-
export const processMd = (mdSource: string) => {
43-
const vfile = remarkHtmlProcessor.processSync(mdSource);
43+
export interface ProcessMdOptions {
44+
wrapFirstList?: boolean;
45+
}
46+
47+
export const processMd = (mdSource: string, options?: ProcessMdOptions) => {
48+
let processor = remarkHtmlProcessor;
49+
50+
// If wrapFirstList is enabled, add the rehype plugin
51+
if (options?.wrapFirstList) {
52+
processor = unified()
53+
.use(parse)
54+
.use(remarkGfm)
55+
.use(remarkHeadings as ReturnType<ReturnType<typeof unified>["use"]>)
56+
.use(remarkRehype, { allowDangerousHtml: true })
57+
.use(rehypeSlug)
58+
.use(rehypeWrapFirstList)
59+
.use(rehypeStringify, { allowDangerousHtml: true });
60+
}
61+
62+
const vfile = processor.processSync(mdSource);
4463
if (hasHeadingsData(vfile.data)) {
4564
return { html: vfile.toString(), headings: vfile.data.headings };
4665
}

src/pages/transcripts/[slug].tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ export const getStaticProps = async ({
8888
props: {
8989
all,
9090
...pick(["title", "date"], doc),
91-
html: processMd(doc.content).html,
91+
html: processMd(doc.content, { wrapFirstList: true }).html,
9292
description: processMdPlaintext(doc.description).html,
9393
},
9494
};

0 commit comments

Comments
 (0)