Skip to content

feat(component): table of contents and anchor links on docs pages#187

Open
adrianschmidt wants to merge 4 commits into
jgroth:mainfrom
Lundalogik:feat/165-toc-upstream
Open

feat(component): table of contents and anchor links on docs pages#187
adrianschmidt wants to merge 4 commits into
jgroth:mainfrom
Lundalogik:feat/165-toc-upstream

Conversation

@adrianschmidt

@adrianschmidt adrianschmidt commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

Re-open of #179

Based on #185, so that needs merging first (or it will be included when this PR is merged — see first commit).

fix: #165

Summary by CodeRabbit

Release Notes

  • New Features

    • Added table of contents component with collapsible sections for easier navigation within documentation
    • Implemented anchor links throughout component documentation, enabling direct linking to specific sections
    • Improved example documentation display with better title and description formatting
  • Style

    • Enhanced typography consistency across headings in documentation sections
  • Tests

    • Added comprehensive test coverage for anchoring, navigation, and documentation utilities

adrianschmidt and others added 4 commits June 11, 2026 17:40
The heading outline of the generated docs skipped levels
(h1 -> h3 -> h5), which violates axe's heading-order rule and makes
screen-reader navigation harder, and the debug route rendered examples
with no heading context at all, so no example markup heading level
could be correct on both pages.

- The "Examples" section heading is now an h2 instead of an h3, but is
  still sized like an h3 (the other section headings are unchanged,
  since they only follow h3 or lower headings).
- Example titles are now h3 instead of h5, but are still sized like h5
  via custom properties that the playground sets on the markdown
  component.
- The debug route now renders the same heading context as the component
  page: the component name as a small h2 and the example title as a
  small h3.

With this, example markup using h4 captions is correct on both the
component page and the debug page.

fix: jgroth#184
Introduce a shared kompendium-anchor component that renders a ¶ paragraph
link next to a heading and highlights persistently when its slug matches
the current URL anchor. Clicking an active anchor removes the fragment via
history.replaceState so the scroll position is preserved.

Add URL/slug helpers in anchors.ts (slugify, firstLine, exampleAnchorId,
currentRoute, anchorHref, entrySlug, uniqueExampleSlugs, SECTION_SLUGS)
shared by the table of contents and the heading anchors, with unit tests.

Co-authored-by: Adrian Schmidt <adrian.schmidt@lime.tech>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
New floating action button that opens an overlay listing the table of
contents. Entries can be collapsible and optionally default-expanded; user
toggles are tracked explicitly so a default-expanded section stays open on
load but can still be closed, and the active URL anchor auto-expands its
ancestor sections. Includes focus management on open/close, Escape/scrim
dismissal, modifier-click handling, and pruning of stale toggle state when
the entries change. Tree helpers live in toc.tree.ts so they can be unit
tested without importing the component module.

Co-authored-by: Adrian Schmidt <adrian.schmidt@lime.tech>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Render kompendium-toc on each component docs page with entries for Examples
and the Properties/Events/Methods/Slots/Styles sections, and add a ¶ anchor
next to every section heading, per-entry heading, and example title. Example
slugs are derived from the title text and de-duplicated, computed once and
shared between the TOC and the rendered ids so they stay in sync. The
playground renders the example title in-place as a heading (split out via
split-docs.ts) so the anchor can sit beside it, and the secondary-hash
change now scrolls from the hashchange handler since stencil-router does not
re-render for fragment-only URL changes.

Closes jgroth#165.

Co-authored-by: Adrian Schmidt <adrian.schmidt@lime.tech>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds slug-based deep-linking to every documentation section via a new kompendium-anchor inline component and anchor utility module, introduces a floating kompendium-toc with collapsible nested entries, and updates all section list templates, the component page, playground, and debug views to wire in the two-ID (legacy + slug) anchoring scheme.

Changes

Anchor deep-linking and floating TOC

Layer / File(s) Summary
Anchor slug utilities and kompendium-anchor component
src/components/component/anchors.ts, src/components/component/anchors.spec.ts, src/components/anchor/anchor.tsx, src/components/anchor/anchor.scss, src/components/component-title.ts, src/components/component/slots.ts, src/components/playground/split-docs.ts, src/components/playground/playground.spec.tsx
Exports SECTION_SLUGS, slugify, entrySlug, uniqueExampleSlugs, currentRoute, anchorHref, firstLine, and exampleAnchorId. Implements the kompendium-anchor inline paragraph-link component that tracks active state from the URL hash. Adds getComponentTitle, slotDisplayName, and splitDocs utilities with full test coverage.
Section list templates updated with slugId anchoring
src/components/component/templates/examples.tsx, src/components/component/templates/examples.spec.tsx, src/components/component/templates/props.tsx, src/components/component/templates/events.tsx, src/components/component/templates/methods.tsx, src/components/component/templates/slots.tsx, src/components/component/templates/style.tsx
All six section renderers gain an optional slugId prop; each conditionally renders a .section-anchor span and a kompendium-anchor in its heading, and each item renderer is curried to produce per-entry anchor IDs via entrySlug. ExampleList adds slugs: string[] and wraps each playground in <div id={slug}>.
TOC data type, tree helpers, and kompendium-toc component
src/components/toc/toc.types.ts, src/components/toc/toc.tree.ts, src/components/toc/toc.tsx, src/components/toc/toc.scss, src/components/toc/toc.spec.tsx
Defines TocEntry with collapsible/defaultExpanded flags. Adds collectIds, findEntryById, and findAncestorsOf tree helpers. Implements the kompendium-toc floating overlay panel with userToggles state, hash-change auto-expand of collapsible ancestors, focus management on open/close, Escape key handling, and responsive SCSS.
Component page, playground, debug, and typography integration
src/components/component/component.tsx, src/components/component/component.scss, src/components/playground/playground.tsx, src/components/playground/playground.scss, src/components/debug/debug.tsx, src/components/debug/debug.scss, src/components/debug/debug.spec.tsx, src/components/markdown/markdown.scss, src/style/global-layout-rules.scss
Component page computes uniqueExampleSlugs, renders <kompendium-toc> from buildTocEntries, passes dual id/slugId to all section lists, and uses getComponentTitle for headings. Playground splits docs into title + body and renders an optional kompendium-anchor. Debug view adds renderHeadings with h2/h3 context headings. Typography rules updated for h3 and section headings.

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant Toc as kompendium-toc
  participant Anchor as kompendium-anchor
  participant anchorUtils as anchors.ts
  participant history as Browser history

  User->>Toc: clicks floating action button
  Toc->>Toc: open = true, focus first link
  User->>Toc: clicks TOC entry link
  Toc->>Toc: handleLinkClick → close()
  User->>Anchor: clicks paragraph-link (already active)
  Anchor->>anchorUtils: currentRoute()
  anchorUtils-->>Anchor: route string
  Anchor->>history: replaceState(route + `#slug`)
  Anchor->>Anchor: updateActive()
  history->>Toc: hashchange event
  Toc->>anchorUtils: getAnchorId()
  anchorUtils-->>Toc: active anchor id
  Toc->>Toc: expandSectionForActiveAnchor() → update userToggles
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • jgroth/kompendium#160: Shares the same component.tsx scroll-on-route-change logic and anchor-scroll utilities that this PR refactors to prefer getAnchorId() over the raw route hash.

Poem

🐇 Hop, hop through the docs I go,
Each section now has an anchor glow!
A pilcrow marks every heading,
The TOC panel keeps the reader threading.
With slugs and hashes neatly spun,
Deep-linking in the warren's done! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 44.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(component): table of contents and anchor links on docs pages' accurately describes the main feature additions across the PR—introducing kompendium-toc and kompendium-anchor components with URL navigation support.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/components/component-title.ts`:
- Around line 6-10: The getComponentTitle function crashes when a tag without
hyphens is passed because accessing title[0] on an empty string throws an error.
Add a guard check after the title variable assignment in getComponentTitle to
handle the case where the title is empty (which occurs when split('-').slice(1)
yields no results), either by returning a sensible default value or the original
tag value. Additionally, locate and remove the duplicate definition of
getComponentTitle mentioned to exist in the codebase to maintain code
maintainability.

In `@src/components/component/component.tsx`:
- Around line 120-176: The renderDocs method calls getComponentTitle(tag) where
tag originates from router parameters, and this can throw if the tag is
malformed or invalid. Verify that the fix to getComponentTitle itself handles
all edge cases and returns a safe default title. If getComponentTitle cannot be
modified, add defensive validation in renderDocs before calling
getComponentTitle(tag) to check whether the tag is valid, and provide a fallback
title value if validation fails to prevent runtime crashes.

In `@src/components/debug/debug.tsx`:
- Around line 80-92: The renderHeadings method calls getComponentTitle with
ownerComponent.tag without first verifying that ownerComponent is defined, which
can cause a crash if the example-to-owner relationship is missing in the docs.
Add a guard clause to check that ownerComponent exists and is defined before
accessing its tag property and passing it to getComponentTitle. This ensures the
method safely handles cases where ownerComponent may be undefined from the
find() operation at line 57.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 7da52e0d-81a2-40c5-9636-e79de46934e6

📥 Commits

Reviewing files that changed from the base of the PR and between ead6df1 and 56331f3.

📒 Files selected for processing (29)
  • src/components/anchor/anchor.scss
  • src/components/anchor/anchor.tsx
  • src/components/component-title.ts
  • src/components/component/anchors.spec.ts
  • src/components/component/anchors.ts
  • src/components/component/component.scss
  • src/components/component/component.tsx
  • src/components/component/slots.ts
  • src/components/component/templates/events.tsx
  • src/components/component/templates/examples.spec.tsx
  • src/components/component/templates/examples.tsx
  • src/components/component/templates/methods.tsx
  • src/components/component/templates/props.tsx
  • src/components/component/templates/slots.tsx
  • src/components/component/templates/style.tsx
  • src/components/debug/debug.scss
  • src/components/debug/debug.spec.tsx
  • src/components/debug/debug.tsx
  • src/components/markdown/markdown.scss
  • src/components/playground/playground.scss
  • src/components/playground/playground.spec.tsx
  • src/components/playground/playground.tsx
  • src/components/playground/split-docs.ts
  • src/components/toc/toc.scss
  • src/components/toc/toc.spec.tsx
  • src/components/toc/toc.tree.ts
  • src/components/toc/toc.tsx
  • src/components/toc/toc.types.ts
  • src/style/global-layout-rules.scss

Comment on lines +6 to +10
export function getComponentTitle(tag: string): string {
const title = tag.split('-').slice(1).join(' ');

return title[0].toLocaleUpperCase() + title.slice(1);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for all call sites of getComponentTitle to check tag sources
rg -nP '\bgetComponentTitle\s*\(' --type=ts --type=tsx -A 2

Repository: jgroth/kompendium

Length of output: 89


🏁 Script executed:

#!/bin/bash
# Search for all call sites of getComponentTitle (correct file type syntax)
rg -nP 'getComponentTitle\s*\(' --type ts -A 2

# Also check the file itself
echo "=== File content ==="
cat -n src/components/component-title.ts

Repository: jgroth/kompendium

Length of output: 1641


🏁 Script executed:

#!/bin/bash
# Search for where tags originate and if they're validated
rg -n "tag" src/components/component-title.ts -B 3 -A 3

Repository: jgroth/kompendium

Length of output: 438


🏁 Script executed:

#!/bin/bash
# Find where component.tag is defined/populated
rg -n "tag\s*[:=]" --type ts -B 2 | head -80

# Check if tags are validated anywhere
rg -n "tag" src/kompendium/menu.ts | grep -E "(validate|check|require|hyphen)"

Repository: jgroth/kompendium

Length of output: 3630


🏁 Script executed:

#!/bin/bash
# Check the interface/type definition for component
rg -n "interface.*[Cc]omponent|type.*[Cc]omponent" --type ts -A 5 | head -100

Repository: jgroth/kompendium

Length of output: 3617


🏁 Script executed:

#!/bin/bash
# Find all tag values used in the codebase
rg -n "\.tag\s*[=:]|tag:\s*['\"]" --type ts -A 1 | head -50

Repository: jgroth/kompendium

Length of output: 2599


Add guard against empty title strings to improve robustness.

The crash scenario is theoretically valid: if a tag without hyphens (e.g., "button") reaches this function, split('-').slice(1) yields an empty array, join() returns "", and accessing title[0] will throw a runtime error. However, all current component tags follow the hyphen-delimited pattern (e.g., kompendium-type, my-button) from Stencil decorators, which are developer-controlled. The proposed guard is still good defensive programming to prevent future crashes if tag sources change or this function is reused.

Also note: getComponentTitle is duplicated in src/kompendium/menu.ts:151; consider removing one definition.

Proposed fix
 export function getComponentTitle(tag: string): string {
     const title = tag.split('-').slice(1).join(' ');
+    if (!title) {
+        return tag;
+    }
 
     return title[0].toLocaleUpperCase() + title.slice(1);
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function getComponentTitle(tag: string): string {
const title = tag.split('-').slice(1).join(' ');
return title[0].toLocaleUpperCase() + title.slice(1);
}
export function getComponentTitle(tag: string): string {
const title = tag.split('-').slice(1).join(' ');
if (!title) {
return tag;
}
return title[0].toLocaleUpperCase() + title.slice(1);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/component-title.ts` around lines 6 - 10, The getComponentTitle
function crashes when a tag without hyphens is passed because accessing title[0]
on an empty string throws an error. Add a guard check after the title variable
assignment in getComponentTitle to handle the case where the title is empty
(which occurs when split('-').slice(1) yields no results), either by returning a
sensible default value or the original tag value. Additionally, locate and
remove the duplicate definition of getComponentTitle mentioned to exist in the
codebase to maintain code maintainability.

Comment on lines +120 to 176
private renderDocs(
tag: string,
component: JsonDocsComponent,
examples: JsonDocsComponent[],
exampleSlugs: string[],
) {
const title = getComponentTitle(tag);
const tags = component.docsTags
.filter(negate(isTag('slot')))
.filter(negate(isTag('exampleComponent')));
const schema = this.schemas.find((s) => s.$id === tag);

// Each section carries two ids by design: a legacy route-based id on
// the heading (e.g. `component/<tag>/examples/`, kept so existing
// sidebar links and `scrollToElement` keep working) and a short,
// URL-fragment-safe `slugId` on a sibling section-anchor span (the
// target for the in-page TOC and ¶ anchors). The route-based id cannot
// double as a hash fragment, hence both coexist.
return [
<h1 id={this.getId()}>{title}</h1>,
<kompendium-markdown text={component.docs} />,
<kompendium-taglist tags={tags} />,
<ExampleList
examples={examples}
slugs={exampleSlugs}
id={this.getId('examples')}
slugId={SECTION_SLUGS.examples}
schema={schema}
propsFactory={this.examplePropsFactory}
/>,
<PropertyList
props={component.props}
id={this.getId('properties')}
slugId={SECTION_SLUGS.properties}
/>,
<EventList
events={component.events}
id={this.getId('events')}
slugId={SECTION_SLUGS.events}
/>,
<EventList events={component.events} id={this.getId('events')} />,
<MethodList
methods={component.methods}
id={this.getId('methods')}
slugId={SECTION_SLUGS.methods}
/>,
<SlotList
slots={component.slots}
id={this.getId('slots')}
slugId={SECTION_SLUGS.slots}
/>,
<StyleList
styles={component.styles}
id={this.getId('styles')}
slugId={SECTION_SLUGS.styles}
/>,
<SlotList slots={component.slots} id={this.getId('slots')} />,
<StyleList styles={component.styles} id={this.getId('styles')} />,
];
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Verify that malformed tags cannot crash getComponentTitle.

Line 126 calls getComponentTitle(tag) where tag comes from this.match.params.name. If the router provides a malformed or single-segment tag, this will throw due to the empty-title bug in getComponentTitle (see review comment on src/components/component-title.ts).

Ensure the fix to getComponentTitle is applied, or add defensive handling here:

🛡️ Defensive fallback (if getComponentTitle cannot be fixed)
-        const title = getComponentTitle(tag);
+        const title = tag.includes('-') ? getComponentTitle(tag) : tag;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/component/component.tsx` around lines 120 - 176, The
renderDocs method calls getComponentTitle(tag) where tag originates from router
parameters, and this can throw if the tag is malformed or invalid. Verify that
the fix to getComponentTitle itself handles all edge cases and returns a safe
default title. If getComponentTitle cannot be modified, add defensive validation
in renderDocs before calling getComponentTitle(tag) to check whether the tag is
valid, and provide a fallback title value if validation fails to prevent runtime
crashes.

Comment on lines +80 to 92
private renderHeadings(
component: JsonDocsComponent,
ownerComponent: JsonDocsComponent,
) {
const exampleTitle = component.docs?.split('\n')[0];

return [
<h2 class="context-heading">
{getComponentTitle(ownerComponent.tag)}
</h2>,
!!exampleTitle && <h3 class="context-heading">{exampleTitle}</h3>,
];
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard against undefined ownerComponent and malformed tags.

Line 88 calls getComponentTitle(ownerComponent.tag) without verifying that ownerComponent (from find() at line 57) is defined. If the example-to-owner relationship is missing in the docs, this will throw. Additionally, getComponentTitle itself will crash on malformed tags (see review on src/components/component-title.ts).

🛡️ Proposed guard
     private renderHeadings(
         component: JsonDocsComponent,
         ownerComponent: JsonDocsComponent,
     ) {
+        if (!ownerComponent) {
+            return null;
+        }
         const exampleTitle = component.docs?.split('\n')[0];
 
         return [
             <h2 class="context-heading">
                 {getComponentTitle(ownerComponent.tag)}
             </h2>,
             !!exampleTitle && <h3 class="context-heading">{exampleTitle}</h3>,
         ];
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/debug/debug.tsx` around lines 80 - 92, The renderHeadings
method calls getComponentTitle with ownerComponent.tag without first verifying
that ownerComponent is defined, which can cause a crash if the example-to-owner
relationship is missing in the docs. Add a guard clause to check that
ownerComponent exists and is defined before accessing its tag property and
passing it to getComponentTitle. This ensures the method safely handles cases
where ownerComponent may be undefined from the find() operation at line 57.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Table of Contents on component pages

2 participants