diff --git a/package-lock.json b/package-lock.json index 8b47cfb1..537d7a22 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,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", @@ -5256,6 +5257,12 @@ "node": ">= 14" } }, + "node_modules/github-slugger": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-1.5.0.tgz", + "integrity": "sha512-wIh+gKBI9Nshz2o46B0B3f5k/W+WI9ZAv6y5Dn5WJ5SK1t0TnDimB4WE5rmTD05ZAIn8HALCZVmCsvj0w0v0lw==", + "license": "ISC" + }, "node_modules/glob": { "version": "7.1.6", "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", @@ -5479,6 +5486,26 @@ "xtend": "^4.0.1" } }, + "node_modules/hast-util-has-property": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/hast-util-has-property/-/hast-util-has-property-1.0.4.tgz", + "integrity": "sha512-ghHup2voGfgFoHMGnaLHOjbYFACKrRh9KFttdCzMCbFoBMJXiNi2+XTrPP8+q6cDJM/RSqlCfVWrjp1H201rZg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-heading-rank": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/hast-util-heading-rank/-/hast-util-heading-rank-1.0.1.tgz", + "integrity": "sha512-P6Hq7RCky9syMevlrN90QWpqWDXCxwIVOfQR2rK6P4GpY4bqjKEuCzoWSRORZ7vz+VgRpLnXimh+mkwvVFjbyQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-is-element": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-1.0.4.tgz", @@ -5521,6 +5548,16 @@ "zwitch": "^1.0.0" } }, + "node_modules/hast-util-to-string": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/hast-util-to-string/-/hast-util-to-string-1.0.4.tgz", + "integrity": "sha512-eK0MxRX47AV2eZ+Lyr18DCpQgodvaS3fAQO2+b9Two9F5HEoRPhiUMNzoXArMJfZi2yieFzUBMRl3HNJ3Jus3w==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-whitespace": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-1.0.4.tgz", @@ -8165,6 +8202,23 @@ "hast-util-raw": "^5.0.0" } }, + "node_modules/rehype-slug": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/rehype-slug/-/rehype-slug-4.0.1.tgz", + "integrity": "sha512-KIlJALf9WfHFF21icwTd2yI2IP+RQRweaxH9ChVGQwRYy36+hiomG4ZSe0yQRyCt+D/vE39LbAcOI/h4O4GPhA==", + "license": "MIT", + "dependencies": { + "github-slugger": "^1.1.1", + "hast-util-has-property": "^1.0.0", + "hast-util-heading-rank": "^1.0.0", + "hast-util-to-string": "^1.0.0", + "unist-util-visit": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/rehype-stringify": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/rehype-stringify/-/rehype-stringify-8.0.0.tgz", @@ -13917,6 +13971,11 @@ "debug": "^4.3.4" } }, + "github-slugger": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-1.5.0.tgz", + "integrity": "sha512-wIh+gKBI9Nshz2o46B0B3f5k/W+WI9ZAv6y5Dn5WJ5SK1t0TnDimB4WE5rmTD05ZAIn8HALCZVmCsvj0w0v0lw==" + }, "glob": { "version": "7.1.6", "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", @@ -14072,6 +14131,16 @@ "xtend": "^4.0.1" } }, + "hast-util-has-property": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/hast-util-has-property/-/hast-util-has-property-1.0.4.tgz", + "integrity": "sha512-ghHup2voGfgFoHMGnaLHOjbYFACKrRh9KFttdCzMCbFoBMJXiNi2+XTrPP8+q6cDJM/RSqlCfVWrjp1H201rZg==" + }, + "hast-util-heading-rank": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/hast-util-heading-rank/-/hast-util-heading-rank-1.0.1.tgz", + "integrity": "sha512-P6Hq7RCky9syMevlrN90QWpqWDXCxwIVOfQR2rK6P4GpY4bqjKEuCzoWSRORZ7vz+VgRpLnXimh+mkwvVFjbyQ==" + }, "hast-util-is-element": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-1.0.4.tgz", @@ -14116,6 +14185,11 @@ "zwitch": "^1.0.0" } }, + "hast-util-to-string": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/hast-util-to-string/-/hast-util-to-string-1.0.4.tgz", + "integrity": "sha512-eK0MxRX47AV2eZ+Lyr18DCpQgodvaS3fAQO2+b9Two9F5HEoRPhiUMNzoXArMJfZi2yieFzUBMRl3HNJ3Jus3w==" + }, "hast-util-whitespace": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-1.0.4.tgz", @@ -15955,6 +16029,18 @@ "hast-util-raw": "^5.0.0" } }, + "rehype-slug": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/rehype-slug/-/rehype-slug-4.0.1.tgz", + "integrity": "sha512-KIlJALf9WfHFF21icwTd2yI2IP+RQRweaxH9ChVGQwRYy36+hiomG4ZSe0yQRyCt+D/vE39LbAcOI/h4O4GPhA==", + "requires": { + "github-slugger": "^1.1.1", + "hast-util-has-property": "^1.0.0", + "hast-util-heading-rank": "^1.0.0", + "hast-util-to-string": "^1.0.0", + "unist-util-visit": "^2.0.0" + } + }, "rehype-stringify": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/rehype-stringify/-/rehype-stringify-8.0.0.tgz", diff --git a/package.json b/package.json index de20903f..f0fd6dd3 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/anchor-scroll.ts b/src/components/anchor-scroll.ts new file mode 100644 index 00000000..5cd0dbea --- /dev/null +++ b/src/components/anchor-scroll.ts @@ -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 }); + } +} diff --git a/src/components/component/component.tsx b/src/components/component/component.tsx index bd667b9f..cde2427b 100644 --- a/src/components/component/component.tsx +++ b/src/components/component/component.tsx @@ -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', @@ -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 { @@ -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) { diff --git a/src/components/markdown/markdown.tsx b/src/components/markdown/markdown.tsx index fff9e450..a459baba 100644 --- a/src/components/markdown/markdown.tsx +++ b/src/components/markdown/markdown.tsx @@ -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 @@ -12,6 +13,8 @@ import { getTypes } from './markdown-types'; styleUrl: 'markdown.scss', }) export class Markdown { + private renderSeq = 0; + /** * The text to render */ @@ -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(); } @@ -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 { diff --git a/src/kompendium/markdown.ts b/src/kompendium/markdown.ts index df057c68..5db1bc56 100644 --- a/src/kompendium/markdown.ts +++ b/src/kompendium/markdown.ts @@ -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'; @@ -29,6 +30,7 @@ export async function markdownToHtml(text: string, types = []): Promise { .use(admonitions, { icons: 'none' }) .use(remark2rehype, { allowDangerousHtml: true }) .use(raw) + .use(slug) .use(typeLinks, { types: types }) .use(kompendiumCode) .use(html) diff --git a/src/kompendium/test/markdown.spec.ts b/src/kompendium/test/markdown.spec.ts index 73e68ebc..f741b5fd 100644 --- a/src/kompendium/test/markdown.spec.ts +++ b/src/kompendium/test/markdown.spec.ts @@ -10,7 +10,7 @@ describe('markdownToHtml()', () => { it('does not return the frontmatter in the html', async () => { const markdown = '---\nkey: value\n---\n# Test'; - const html = '

Test

'; + const html = '

Test

'; const result = await markdownToHtml(markdown); expect(result.toString()).toEqual(html); }); @@ -22,7 +22,7 @@ describe('markdownToHtml()', () => { const html = `
-
test
+
test

Hello, World!

@@ -44,6 +44,21 @@ describe('markdownToHtml()', () => { }); }); + describe('when markdown contains headings', () => { + it('adds id attributes based on heading text', async () => { + const markdown = '# Hello World\n\n## Getting Started'; + const result = await markdownToHtml(markdown); + expect(result.toString()).toContain('id="hello-world"'); + expect(result.toString()).toContain('id="getting-started"'); + }); + + it('handles special characters in headings', async () => { + const markdown = "# What's New in v2.0?"; + const result = await markdownToHtml(markdown); + expect(result.toString()).toContain('id="whats-new-in-v20"'); + }); + }); + describe('when there is a codeblock with no language', () => { it('uses normal code formatting for the block', async () => { const markdown = '```\ncode\n```';