Skip to content
Merged
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
86 changes: 86 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@
"lodash": "^4.17.23",
"node-cleanup": "^2.1.2",
"rehype-raw": "^4.0.2",
"rehype-slug": "^4.0.1",
"rehype-stringify": "^8.0.0",
"remark-admonitions": "^1.2.1",
"remark-frontmatter": "^2.0.0",
Expand Down
74 changes: 74 additions & 0 deletions src/components/anchor-scroll.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
* Utility functions for handling anchor link scrolling in shadow DOM components.
*/

/**
* Get the current route from the URL hash (without the leading #).
*
* For hash-based routing where the entire hash is the route.
* Example: "#/component/limel-button/examples/" → "/component/limel-button/examples/"
* @returns {string} The route extracted from the URL hash
*/
export function getRoute(): string {
return window.location.hash.substring(1);
}

/**
* Extract the anchor ID from the current URL hash.
*
* Handles both simple anchors (#section) and route-based anchors (#/guide/page#section).
* Returns null if no valid anchor is found.
*
* Example: "#/guide/changelog#v2-features" → "v2-features"
* @returns {string | null} The anchor ID or null if not found
*/
export function getAnchorId(): string | null {
const hash = window.location.hash;
if (!hash) {
return null;
}

// Match the last #fragment in the hash
// This handles both "#section" and "#/route/path#section"
const anchorMatch = hash.match(/#([^#]+)$/);

return anchorMatch ? anchorMatch[1] : null;
}

/**
* Scroll to an anchor element within a shadow root.
*
* Uses requestAnimationFrame to ensure the DOM is ready before scrolling.
* @param {ShadowRoot} shadowRoot - The shadow root to search for the element
* @param {ScrollBehavior} behavior - Scroll behavior ('auto' or 'smooth')
*/
export function scrollToAnchor(
shadowRoot: ShadowRoot,
behavior: ScrollBehavior = 'auto',
): void {
const anchorId = getAnchorId();
if (!anchorId) {
return;
}

requestAnimationFrame(() => {
scrollToElement(shadowRoot, anchorId, behavior);
});
}

/**
* Scroll to a specific element by ID within a shadow root.
* @param {ShadowRoot} shadowRoot - The shadow root to search for the element
* @param {string} id - The element ID to scroll to
* @param {ScrollBehavior} behavior - Scroll behavior ('auto' or 'smooth')
*/
export function scrollToElement(
shadowRoot: ShadowRoot,
id: string,
behavior: ScrollBehavior = 'auto',
): void {
const element = shadowRoot.getElementById(id);
if (element) {
element.scrollIntoView({ behavior: behavior });
}
}
26 changes: 7 additions & 19 deletions src/components/component/component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { StyleList } from './templates/style';
import { ExampleList } from './templates/examples';
import negate from 'lodash/negate';
import { PropsFactory } from '../playground/playground.types';
import { getRoute, scrollToElement } from '../anchor-scroll';

@Component({
tag: 'kompendium-component',
Expand Down Expand Up @@ -62,29 +63,20 @@ export class KompendiumComponent {
}

protected componentDidLoad(): void {
const route = this.getRoute();
this.scrollToElement(route);
const route = getRoute().split('#')[0];
scrollToElement(this.host.shadowRoot, route);
}

protected componentDidUpdate(): void {
if (this.scrollToOnNextUpdate) {
this.scrollToElement(this.scrollToOnNextUpdate);
const route = this.scrollToOnNextUpdate.split('#')[0];
scrollToElement(this.host.shadowRoot, route);
this.scrollToOnNextUpdate = null;
}
}

private handleRouteChange() {
const route = this.getRoute();
this.scrollToOnNextUpdate = route;
}

private scrollToElement(id: string) {
const element = this.host.shadowRoot.getElementById(id);
if (!element) {
return;
}

element.scrollIntoView();
this.scrollToOnNextUpdate = getRoute().split('#')[0];
}

public render(): HTMLElement {
Expand Down Expand Up @@ -134,14 +126,10 @@ export class KompendiumComponent {
}

private getId(name?: string) {
const route = this.getRoute().split('/').slice(0, 3).join('/');
const route = getRoute().split('#')[0].split('/').slice(0, 3).join('/');

return [route, name].filter((item) => !!item).join('/') + '/';
}

private getRoute() {
return location.hash.substr(1);
}
}

function findExamples(component: JsonDocsComponent, docs: JsonDocs) {
Expand Down
32 changes: 31 additions & 1 deletion src/components/markdown/markdown.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Component, h, Prop, Element } from '@stencil/core';
import { markdownToHtml } from '../../kompendium/markdown';
import { getTypes } from './markdown-types';
import { scrollToAnchor } from '../anchor-scroll';

/**
* This component renders markdown
Expand All @@ -12,6 +13,8 @@ import { getTypes } from './markdown-types';
styleUrl: 'markdown.scss',
})
export class Markdown {
private renderSeq = 0;

/**
* The text to render
*/
Expand All @@ -21,6 +24,18 @@ export class Markdown {
@Element()
private host: HTMLKompendiumMarkdownElement;

constructor() {
this.handleHashChange = this.handleHashChange.bind(this);
}

protected connectedCallback(): void {
window.addEventListener('hashchange', this.handleHashChange);
}

protected disconnectedCallback(): void {
window.removeEventListener('hashchange', this.handleHashChange);
}

protected componentDidLoad(): void {
this.renderMarkdown();
}
Expand All @@ -29,11 +44,26 @@ export class Markdown {
this.renderMarkdown();
}

private handleHashChange(): void {
scrollToAnchor(this.host.shadowRoot);
}

private async renderMarkdown() {
const renderSeq = ++this.renderSeq;
const currentText = this.text;
const types = getTypes();
const file = await markdownToHtml(this.text, types);
const file = await markdownToHtml(currentText, types);

// Abort if a newer render has started or text has changed
if (renderSeq !== this.renderSeq || currentText !== this.text) {
return;
}

this.host.shadowRoot.querySelector('#root').innerHTML =
file?.toString();

// After content renders, scroll to anchor if present in URL
scrollToAnchor(this.host.shadowRoot);
}

render(): HTMLElement {
Expand Down
2 changes: 2 additions & 0 deletions src/kompendium/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import parseFrontmatter from 'remark-parse-yaml';
import admonitions from 'remark-admonitions';
import remark2rehype from 'remark-rehype';
import raw from 'rehype-raw';
import slug from 'rehype-slug';
import html from 'rehype-stringify';
import { saveFrontmatter } from './markdown-frontmatter';
import { kompendiumCode } from './markdown-code';
Expand All @@ -29,6 +30,7 @@ export async function markdownToHtml(text: string, types = []): Promise<File> {
.use(admonitions, { icons: 'none' })
.use(remark2rehype, { allowDangerousHtml: true })
.use(raw)
.use(slug)
.use(typeLinks, { types: types })
.use(kompendiumCode)
.use(html)
Expand Down
Loading