Frameworks · one recipe, everywhere
Every framework.
No adapters.
Chapterize JS is plain DOM, so there is no React package or Vue plugin to keep in sync. Render a nav, call init() once on the client, and the DOM watching does the rest.
How it fits #
- Safe on the server. Importing does nothing until
init()runs, andinit()is a no-op without adocument. - After hydration. Call
init()in your framework's "mounted" hook, so the built list never causes a hydration mismatch. - Routes and re-renders. One MutationObserver notices new headings, replaced pages and navs that mount or unmount. Instances clean up after themselves.
- Idempotent. Calling
init()again (Strict Mode, every route) returns the same controller. - Router links.
/docs#setuplinks and<base href>count as same-page links; hash routers (#/route) keep their URL.
Recipes #
Chapterize JS is plain DOM, so the recipe is the same everywhere: render a nav with data-chapter, and call init() once on the client after the first render. init() is idempotent and keeps watching the DOM, so route changes, re-rendered headings and navs that mount later are picked up without more code. When a nav leaves the DOM its instance is destroyed for you.
Plain HTML #
<nav data-chapter data-chapter-build="article h2, article h3" data-chapter-marker></nav>
<article>…</article>
<script src="https://unpkg.com/chapterize-js" defer></script>React #
Call init() in an effect, after hydration, so the built list never causes a hydration mismatch. Strict Mode's double effect is harmless.
import { useEffect } from 'react';
import { init } from 'chapterize-js';
export function Toc() {
useEffect(() => { init(); }, []);
return <nav data-chapter data-chapter-build="article h2, article h3" data-chapter-marker />;
}Next.js #
App Router: put the nav in any Server Component and render one small Client Component that calls init(), once, in a layout. Pages stay static, the nav ships no JavaScript of its own, and on client-side navigation (next/link) the DOM watching rebuilds the outline from the new page.
'use client';
import { useEffect } from 'react';
import { init } from 'chapterize-js';
export default function ChapterInit() {
useEffect(() => { init(); }, []);
return null;
}import ChapterInit from './chapter-init';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{children}
<ChapterInit />
</body>
</html>
);
}export default function Page() {
return (
<>
<nav data-chapter data-chapter-build="article h2, article h3" data-chapter-marker />
<article>{/* headings and content */}</article>
</>
);
}Vue #
<script setup>
import { onMounted } from 'vue';
import { init } from 'chapterize-js';
onMounted(() => init());
</script>
<template>
<nav data-chapter data-chapter-build="article h2, article h3" data-chapter-marker></nav>
</template>Nuxt #
A client plugin that waits for the app to mount (so hydration sees the server markup first).
// plugins/toc.client.js
import { init } from 'chapterize-js';
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.hook('app:mounted', () => init());
});Svelte / SvelteKit #
<script>
import { onMount } from 'svelte';
import { init } from 'chapterize-js';
onMount(() => init());
</script>
<nav data-chapter data-chapter-build="article h2, article h3" data-chapter-marker></nav>Angular #
afterNextRender runs only in the browser, after the view is in the DOM. Links work with <base href>.
import { Component, afterNextRender } from '@angular/core';
import { init } from 'chapterize-js';
@Component({
selector: 'app-toc',
template: `<nav data-chapter data-chapter-build="article h2, article h3" data-chapter-marker></nav>`,
})
export class TocComponent {
constructor() { afterNextRender(() => init()); }
}Astro #
A bundled script runs once per page; with <ClientRouter /> the DOM watching follows each swap.
<nav data-chapter data-chapter-build="article h2, article h3" data-chapter-marker></nav>
<script>
import 'chapterize-js/auto';
</script>Solid #
import { onMount } from 'solid-js';
import { init } from 'chapterize-js';
export function Toc() {
onMount(() => init());
return <nav data-chapter data-chapter-build="article h2, article h3" data-chapter-marker />;
}WordPress #
Enqueue the global build in the theme, and put the nav in a template part or a Custom HTML block.
add_action('wp_enqueue_scripts', function () {
wp_enqueue_script('chapterize-js', 'https://unpkg.com/chapterize-js', [], null, ['strategy' => 'defer']);
});<nav data-chapter data-chapter-build=".entry-content h2, .wp-block-post-content h2" data-chapter-marker></nav>Hugo #
Hugo's headings already have ids, which are kept. Build the outline, or spy Hugo's own .TableOfContents.
<nav data-chapter data-chapter-build=".content h2, .content h3" data-chapter-marker></nav>
<!-- or: <div data-chapter data-chapter-marker>{{ .TableOfContents }}</div> -->
<script src="https://unpkg.com/chapterize-js" defer></script>Eleventy #
In the layout that wraps your Markdown:
<nav data-chapter data-chapter-build="main h2, main h3" data-chapter-marker></nav>
<main>{{ content | safe }}</main>
<script src="https://unpkg.com/chapterize-js" defer></script>Drupal #
Declare a library in the theme, attach it, and add the nav to the node template.
# mytheme.libraries.yml
toc:
js:
https://unpkg.com/chapterize-js: { type: external, minified: true, attributes: { defer: true } }{{ attach_library('mytheme/toc') }}
<nav data-chapter data-chapter-build=".node__content h2, .node__content h3" data-chapter-marker></nav>