Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feature: Table of Contents #202

Draft
wants to merge 8 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2,077 changes: 136 additions & 1,941 deletions package-lock.json

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,10 @@
"datocms-listen": "^0.1.15",
"datocms-structured-text-utils": "^2.0.4",
"get-video-id": "^3.6.5",
"github-slugger": "^2.0.0",
"globby": "^13.2.2",
"hast-util-from-html": "^2.0.3",
jbmoelker marked this conversation as resolved.
Show resolved Hide resolved
"hast-util-to-string": "^3.0.1",
"hls.js": "^1.5.2",
"html-validate": "^8.7.4",
"jiti": "^1.20.0",
Expand All @@ -60,7 +63,8 @@
"promise-all-props": "^3.0.0",
"regexparam": "^3.0.0",
"rosetta": "^1.1.0",
"typescript": "^5.6.3"
"typescript": "^5.6.3",
"unist-util-visit": "^5.0.0"
},
"devDependencies": {
"@astrojs/ts-plugin": "^1.10.2",
Expand Down
4 changes: 2 additions & 2 deletions src/blocks/TextBlock/nodes/Heading.astro
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
---
import type { Heading } from 'datocms-structured-text-utils';
import HeadingComponent from '@components/Heading/Heading.astro';

interface Props {
node: Heading;
Expand All @@ -12,6 +13,5 @@ const minimumLevel = 2;
const defaultLevel = 2;
const level = Math.max(minimumLevel, (node.level || defaultLevel));

const Tag = `h${level}`;
---
<Tag><slot /></Tag>
<HeadingComponent level={ level }><slot /></HeadingComponent>
20 changes: 20 additions & 0 deletions src/components/Heading/Heading.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
import type { HTMLTag } from 'astro/types';
import type { Heading } from 'datocms-structured-text-utils';
import { htmlIdSlug } from '@components/HtmlIdSlugProvider/';

interface Props {
id: string;
level: Heading['level'];
}
const { id, level = 2, ...props } = Astro.props;

const slug = htmlIdSlug({
id,
html: await Astro.slots.render('default')
});

const Tag = `h${level}` as HTMLTag;
---

<Tag {...props} id={ slug } tabindex="-1"><slot /></Tag>
7 changes: 7 additions & 0 deletions src/components/HtmlIdSlugProvider/HtmlIdSlugProvider.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
import { resetHtmlIdSlugger } from './index';

resetHtmlIdSlugger();
---

<slot />
Empty file.
18 changes: 18 additions & 0 deletions src/components/HtmlIdSlugProvider/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import GithubSlugger from 'github-slugger';
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Placing this script and export the resetHtmlIdSlugger in the astro file caused the compiler not to be able to find slugger. Extracting it to this file solves that issue. But the new problem is that now this script isn't really scoped to the provider anymore.

May need to use a different mechanism, like https://docs.astro.build/en/recipes/sharing-state/ ?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or maybe we could use an actual context with this library: https://withastro-utils.github.io/docs/guides/context/

import { fromHtml } from 'hast-util-from-html';
import { toString } from 'hast-util-to-string';

const slugger = new GithubSlugger();

export const resetHtmlIdSlugger = () => {
slugger.reset();
};

export const htmlIdSlug = ({ id, html }: { id?: string; html: string }) => {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should add test coverage for these methods

if (id) {
return slugger.slug(id);
}
const tree = fromHtml(html, { fragment: true });
const text = toString(tree);
return slugger.slug(text);
};
19 changes: 19 additions & 0 deletions src/components/TableOfContents/List.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
import type { TreeItem } from './index';

interface Props {
items: TreeItem[];
}
const { items } = Astro.props;
---

<ol>
{ items.map((item) => (
<li class:list={[ `level-${item.level}` ]}>
<a href={ `#${ item.id }` }>{ item.text }</a>
{ item.items && (
<Astro.self items={ item.items } />
) }
</li>
)) }
</ol>
3 changes: 3 additions & 0 deletions src/components/TableOfContents/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Table of Contents

**Render a list of links to headings in a given HTML string.**
18 changes: 18 additions & 0 deletions src/components/TableOfContents/TableOfContents.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
import { t } from '@lib/i18n';
import { extractTocItemsFromHtml } from './index';
import List from './List.astro';

const html = await Astro.slots.render('default');
const items = extractTocItemsFromHtml(html);
---

{
items.length > 0 && (
<nav>
<h2>{t('table_of_contents')}</h2>
<List items={items} />
</nav>
)
}
<Fragment set:html={html} />
37 changes: 37 additions & 0 deletions src/components/TableOfContents/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { fromHtml } from 'hast-util-from-html';
import { toString } from 'hast-util-to-string';
import { visit } from 'unist-util-visit';

export type TreeItem = {
id: string;
level: number;
text: string;
items?: TreeItem[];
};

export const extractTocItemsFromHtml = (html: string) => {

const hast = fromHtml(html, { fragment: true });
const tagNames = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']);
const items: TreeItem[] = [];

visit(hast, 'element', (node) => {
if (tagNames.has(node.tagName) && node.properties?.id) {
const text = toString(node);
const item: TreeItem = {
id: String(node.properties.id),
level: parseInt(node.tagName.slice(1), 10),
text,
};
const lastItem = items[items.length - 1];
if (lastItem && lastItem.level < item.level) {
lastItem.items = lastItem.items ?? [];
lastItem.items.push(item);
} else {
items.push(item);
}
}
});

return items;
};
49 changes: 26 additions & 23 deletions src/layouts/Default.astro
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type { PageUrl } from '@lib/seo';
import query from './default.query.graphql';
import Breadcrumbs from '@components/Breadcrumbs/Breadcrumbs.astro';
import type { Breadcrumb } from '@components/Breadcrumbs';
import HtmlIdSlugProvider from '@components/HtmlIdSlugProvider/HtmlIdSlugProvider.astro';
import IconSprite from '@components/Icon/IconSprite.astro';
import LocaleSelector from '@components/LocaleSelector/LocaleSelector.astro';
import PerfHead from '@components/PerfHead/PerfHead.astro';
Expand Down Expand Up @@ -52,31 +53,33 @@ const mainContentId = 'content';
</head>
<body>
<PreviewModeProvider>
<SkipLink targetId={mainContentId} />
<header {...datocmsNoIndex}>
<HtmlIdSlugProvider>
<SkipLink targetId={mainContentId} />
<header {...datocmsNoIndex}>
{
/* accessible home link, inspired by https://www.gov.uk/; with Microformats rel */
}
<a
rel="home"
href={getHomeHref()}
aria-label={t('go_to_home_page', { siteName: siteName() })}>[Logo]</a
>
<div>
<LocaleSelector pageUrls={pageUrls} />
<a rel="search" href={getSearchPathname(locale)}>{t('search')}</a>
</div>
</header>
{breadcrumbs.length > 0 && <Breadcrumbs items={breadcrumbs} />}
{
/* accessible home link, inspired by https://www.gov.uk/; with Microformats rel */
/* main element requires tabindex to be focusable, see SkipLink/README.md */
}
<a
rel="home"
href={getHomeHref()}
aria-label={t('go_to_home_page', { siteName: siteName() })}>[Logo]</a
>
<div>
<LocaleSelector pageUrls={pageUrls} />
<a rel="search" href={getSearchPathname(locale)}>{t('search')}</a>
</div>
</header>
{breadcrumbs.length > 0 && <Breadcrumbs items={breadcrumbs} />}
{
/* main element requires tabindex to be focusable, see SkipLink/README.md */
}
<main id={mainContentId} tabindex="-1">
<slot />
</main>
<footer {...datocmsNoIndex}>
<p>Footer</p>
</footer>
<main id={mainContentId} tabindex="-1">
<slot />
</main>
<footer {...datocmsNoIndex}>
<p>Footer</p>
</footer>
</HtmlIdSlugProvider>
</PreviewModeProvider>
<IconSprite />
</body>
Expand Down
5 changes: 4 additions & 1 deletion src/pages/[locale]/[...path]/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import Blocks from '@blocks/Blocks.astro';
import type { AnyBlock } from '@blocks/Blocks';
import { formatBreadcrumb } from '@components/Breadcrumbs';
import PreviewModeSubscription from '@components/PreviewMode/PreviewModeSubscription.astro';
import TableOfContents from '@components/TableOfContents/TableOfContents.astro';
import query from './_index.query.graphql';

export async function getStaticPaths() {
Expand Down Expand Up @@ -75,5 +76,7 @@ const pageUrls = (page._allSlugLocales || []).map(({ locale }) => ({
>
<PreviewModeSubscription query={query} variables={variables} />
<h1>{page.title}</h1>
<Blocks blocks={page.bodyBlocks as AnyBlock[]} />
<TableOfContents>
<Blocks blocks={page.bodyBlocks as AnyBlock[]} />
</TableOfContents>
</Layout>
2 changes: 1 addition & 1 deletion src/pages/[locale]/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import type { PageUrl } from '@lib/seo';
import { locales } from '@lib/i18n';
import { getHomeHref } from '@lib/routing';
import Layout from '@layouts/Default.astro';
import Blocks from '@blocks/Blocks.astro';
import type { AnyBlock } from '@blocks/Blocks';
import Blocks from '@blocks/Blocks.astro';
import PreviewModeSubscription from '@components/PreviewMode/PreviewModeSubscription.astro';
import query from './_index.query.graphql';

Expand Down
Loading