');
+ });
+
+ it('should handle entities at start and end', () => {
+ expect(decodeHtmlEntities('&start and end&')).toBe('&start and end&');
+ });
+
+ it('should handle multiple consecutive same entities', () => {
+ expect(decodeHtmlEntities('&&&')).toBe('&&&');
+ });
+
+ it('should decode Array pattern (common in code)', () => {
+ expect(decodeHtmlEntities('Array<string>')).toBe('Array');
+ });
+
+ it('should decode generic TypeScript code pattern', () => {
+ expect(decodeHtmlEntities('Map<string, number>')).toBe('Map');
+ });
+});
+
+describe('escapeHtml', () => {
+ it('should return empty string for empty input', () => {
+ expect(escapeHtml('')).toBe('');
+ });
+
+ it('should return text unchanged when no special chars present', () => {
+ expect(escapeHtml('Hello World')).toBe('Hello World');
+ });
+
+ it('should escape & to &', () => {
+ expect(escapeHtml('Tom & Jerry')).toBe('Tom & Jerry');
+ });
+
+ it('should escape " to "', () => {
+ expect(escapeHtml('He said "hello"')).toBe('He said "hello"');
+ });
+
+ it('should escape < to <', () => {
+ expect(escapeHtml('a < b')).toBe('a < b');
+ });
+
+ it('should escape > to >', () => {
+ expect(escapeHtml('a > b')).toBe('a > b');
+ });
+
+ it("should escape ' to '", () => {
+ expect(escapeHtml("It's fine")).toBe("It's fine");
+ });
+
+ it('should escape all special characters in one string', () => {
+ expect(escapeHtml('
&
')).toBe('<div class="test">&</div>');
+ });
+
+ it('should handle multiple ampersands correctly', () => {
+ // Ampersands must be escaped first to avoid double-escaping
+ expect(escapeHtml('a & b & c')).toBe('a & b & c');
+ });
+
+ it('should escape HTML tag patterns', () => {
+ expect(escapeHtml('')).toBe('<script>alert("xss")</script>');
+ });
+
+ it('should escape TypeScript generic syntax', () => {
+ expect(escapeHtml('Array')).toBe('Array<string>');
+ });
+
+ it('should be reversible with decodeHtmlEntities', () => {
+ const original = 'Tom & Jerry <3 "quotes"';
+ const escaped = escapeHtml(original);
+ const decoded = decodeHtmlEntities(escaped);
+ expect(decoded).toBe(original);
+ });
+});
+
+describe('escapeHtml and decodeHtmlEntities roundtrip', () => {
+ const testCases = [
+ 'Simple text',
+ 'Tom & Jerry',
+ 'a < b > c',
+ 'He said "hello"',
+ "It's fine",
+ '
Content & more
',
+ 'Array
`;
- const parser = new JekyllMarkdownParser(baseUrl);
+ const parser = new JekyllMarkdownParser(baseUrl, linkBasePath);
const result = parser.parse(input);
expect(result.html).toContain('
');
@@ -806,7 +806,7 @@ title: Test
`;
- const parser = new JekyllMarkdownParser(baseUrl);
+ const parser = new JekyllMarkdownParser(baseUrl, linkBasePath);
const result = parser.parse(input);
expect(result.html).toContain('class="rounded shadow"');
@@ -824,7 +824,7 @@ title: Test
\`\`\`
`;
- const parser = new JekyllMarkdownParser(baseUrl);
+ const parser = new JekyllMarkdownParser(baseUrl, linkBasePath);
const result = parser.parse(input);
// Code is escaped and syntax-highlighted by highlight.js
@@ -845,7 +845,7 @@ title: Test
\`\`\`
`;
- const parser = new JekyllMarkdownParser(baseUrl);
+ const parser = new JekyllMarkdownParser(baseUrl, linkBasePath);
const result = parser.parse(input);
expect(result.html).toContain(`src="${baseUrl}real-image.jpg"`);
@@ -869,7 +869,7 @@ title: Test

`;
- const parser = new JekyllMarkdownParser(baseUrl);
+ const parser = new JekyllMarkdownParser(baseUrl, linkBasePath);
const result = parser.parse(input);
expect(result.html).toContain('src="http://insecure.com/image.png"');
@@ -883,7 +883,7 @@ title: Test
`;
- const parser = new JekyllMarkdownParser(baseUrl);
+ const parser = new JekyllMarkdownParser(baseUrl, linkBasePath);
const result = parser.parse(input);
expect(result.html).toContain('src="http://insecure.com/image.png"');
@@ -899,7 +899,7 @@ title: Test

`;
- const parser = new JekyllMarkdownParser(baseUrl);
+ const parser = new JekyllMarkdownParser(baseUrl, linkBasePath);
const result = parser.parse(input);
// Quotes should be escaped to prevent broken HTML
@@ -918,7 +918,7 @@ title: Test

`;
- const parser = new JekyllMarkdownParser(baseUrl);
+ const parser = new JekyllMarkdownParser(baseUrl, linkBasePath);
const result = parser.parse(input);
// Marked does NOT parse this as an image - it becomes literal text
@@ -933,7 +933,7 @@ title: Test

`;
- const parser = new JekyllMarkdownParser(baseUrl);
+ const parser = new JekyllMarkdownParser(baseUrl, linkBasePath);
const result = parser.parse(input);
expect(result.html).toContain('alt="Array<string>"');
@@ -946,7 +946,7 @@ title: Test

`;
- const parser = new JekyllMarkdownParser(baseUrl);
+ const parser = new JekyllMarkdownParser(baseUrl, linkBasePath);
const result = parser.parse(input);
expect(result.html).toContain('alt="Tom & Jerry"');
@@ -956,7 +956,7 @@ title: Test
describe('YAML frontmatter edge cases', () => {
it('should handle Windows line endings (CRLF)', () => {
const input = '---\r\ntitle: Test\r\n---\r\n\r\n# Hello';
- const parser = new JekyllMarkdownParser(baseUrl);
+ const parser = new JekyllMarkdownParser(baseUrl, linkBasePath);
const { parsedYaml, html } = parser.parse(input);
expect(parsedYaml.title).toBe('Test');
@@ -966,7 +966,7 @@ title: Test
it('should throw for only one separator (no valid frontmatter)', () => {
const input = '---\nThis is not YAML, just a horizontal rule\n\n# Hello';
- const parser = new JekyllMarkdownParser(baseUrl);
+ const parser = new JekyllMarkdownParser(baseUrl, linkBasePath);
expect(() => parser.parse(input)).toThrow('YAML frontmatter is required');
});
@@ -982,7 +982,7 @@ title: Test
This is after a horizontal rule.
`;
- const parser = new JekyllMarkdownParser(baseUrl);
+ const parser = new JekyllMarkdownParser(baseUrl, linkBasePath);
const result = parser.parse(input);
expect(result.parsedYaml.title).toBe('Test');
@@ -992,7 +992,7 @@ This is after a horizontal rule.
it('should handle trailing whitespace after --- separator', () => {
const input = '--- \ntitle: Test\n---\t\n\n# Hello';
- const parser = new JekyllMarkdownParser(baseUrl);
+ const parser = new JekyllMarkdownParser(baseUrl, linkBasePath);
const result = parser.parse(input);
expect(result.parsedYaml.title).toBe('Test');
@@ -1008,13 +1008,14 @@ This is after a horizontal rule.
// 2. transformRelativeImagePaths runs on the ENTIRE HTML output
// 3. It must NOT add baseUrl again to URLs that already start with the placeholder
const placeholderBaseUrl = `${MARKDOWN_BASE_URL_PLACEHOLDER}/blog/my-post/`;
+ const placeholderLinkPath = '/blog/my-post';
const input = `---
title: Test
---

`;
- const parser = new JekyllMarkdownParser(placeholderBaseUrl);
+ const parser = new JekyllMarkdownParser(placeholderBaseUrl, placeholderLinkPath);
const result = parser.parse(input);
// Should have exactly ONE placeholder prefix, not two!
@@ -1025,13 +1026,14 @@ title: Test
it('should NOT double-prefix raw HTML images with placeholder in src', () => {
// Edge case: What if someone manually writes the placeholder in HTML?
const placeholderBaseUrl = `${MARKDOWN_BASE_URL_PLACEHOLDER}/blog/my-post/`;
+ const placeholderLinkPath = '/blog/my-post';
const input = `---
title: Test
---
`;
- const parser = new JekyllMarkdownParser(placeholderBaseUrl);
+ const parser = new JekyllMarkdownParser(placeholderBaseUrl, placeholderLinkPath);
const result = parser.parse(input);
// Should NOT add another prefix
@@ -1040,6 +1042,284 @@ title: Test
});
});
+ describe('Relative link transformation', () => {
+ it('should transform #anchor to absolute path', () => {
+ const input = `---
+title: Test
+---
+
+Check the [introduction](#introduction) section.
+`;
+ const parser = new JekyllMarkdownParser(baseUrl, linkBasePath);
+ const result = parser.parse(input);
+
+ expect(result.html).toContain('href="/blog/my-post#introduction"');
+ });
+
+ it('should transform ../sibling-slug to absolute path', () => {
+ const input = `---
+title: Test
+---
+
+See [other article](../other-post) for more.
+`;
+ const parser = new JekyllMarkdownParser(baseUrl, linkBasePath);
+ const result = parser.parse(input);
+
+ expect(result.html).toContain('href="/blog/other-post"');
+ });
+
+ it('should transform ../sibling-slug#section to absolute path with anchor', () => {
+ const input = `---
+title: Test
+---
+
+See [Angular 10](../2020-06-angular10#setup) for details.
+`;
+ const parser = new JekyllMarkdownParser(baseUrl, linkBasePath);
+ const result = parser.parse(input);
+
+ expect(result.html).toContain('href="/blog/2020-06-angular10#setup"');
+ });
+
+ it('should NOT transform external https:// links', () => {
+ const input = `---
+title: Test
+---
+
+Check [Angular docs](https://angular.io/docs) for more.
+`;
+ const parser = new JekyllMarkdownParser(baseUrl, linkBasePath);
+ const result = parser.parse(input);
+
+ expect(result.html).toContain('href="https://angular.io/docs"');
+ });
+
+ it('should NOT transform external http:// links', () => {
+ const input = `---
+title: Test
+---
+
+Check [old site](http://example.com) for more.
+`;
+ const parser = new JekyllMarkdownParser(baseUrl, linkBasePath);
+ const result = parser.parse(input);
+
+ expect(result.html).toContain('href="http://example.com"');
+ });
+
+ it('should NOT transform already-absolute paths starting with /', () => {
+ const input = `---
+title: Test
+---
+
+Check [another post](/blog/2023-01-other-post) for more.
+`;
+ const parser = new JekyllMarkdownParser(baseUrl, linkBasePath);
+ const result = parser.parse(input);
+
+ expect(result.html).toContain('href="/blog/2023-01-other-post"');
+ });
+
+ it('should NOT transform already-absolute paths with hash', () => {
+ const input = `---
+title: Test
+---
+
+Check [section](/blog/2023-01-other-post#setup) for more.
+`;
+ const parser = new JekyllMarkdownParser(baseUrl, linkBasePath);
+ const result = parser.parse(input);
+
+ expect(result.html).toContain('href="/blog/2023-01-other-post#setup"');
+ });
+
+ it('should NOT transform absolute paths in raw HTML anchor tags', () => {
+ const input = `---
+title: Test
+---
+
+Other post
+Section link
+`;
+ const parser = new JekyllMarkdownParser(baseUrl, linkBasePath);
+ const result = parser.parse(input);
+
+ expect(result.html).toContain('href="/blog/other-post"');
+ expect(result.html).toContain('href="/blog/other-post#section"');
+ });
+
+ it('should NOT transform https:// links in raw HTML anchor tags', () => {
+ const input = `---
+title: Test
+---
+
+Angular Docs
+`;
+ const parser = new JekyllMarkdownParser(baseUrl, linkBasePath);
+ const result = parser.parse(input);
+
+ expect(result.html).toContain('href="https://angular.io/guide/components"');
+ });
+
+ it('should NOT transform mailto: links', () => {
+ const input = `---
+title: Test
+---
+
+Contact us at [team@example.com](mailto:team@example.com).
+`;
+ const parser = new JekyllMarkdownParser(baseUrl, linkBasePath);
+ const result = parser.parse(input);
+
+ expect(result.html).toContain('href="mailto:team@example.com"');
+ });
+
+ it('should NOT transform tel: links', () => {
+ const input = `---
+title: Test
+---
+
+Call us at [+49 123 456](tel:+49123456).
+`;
+ const parser = new JekyllMarkdownParser(baseUrl, linkBasePath);
+ const result = parser.parse(input);
+
+ expect(result.html).toContain('href="tel:+49123456"');
+ });
+
+ it('should NOT transform ftp:// links', () => {
+ const input = `---
+title: Test
+---
+
+Download from [FTP](ftp://files.example.com/file.zip).
+`;
+ const parser = new JekyllMarkdownParser(baseUrl, linkBasePath);
+ const result = parser.parse(input);
+
+ expect(result.html).toContain('href="ftp://files.example.com/file.zip"');
+ });
+
+ it('should NOT transform mailto: in raw HTML', () => {
+ const input = `---
+title: Test
+---
+
+Mail
+`;
+ const parser = new JekyllMarkdownParser(baseUrl, linkBasePath);
+ const result = parser.parse(input);
+
+ expect(result.html).toContain('href="mailto:team@angular-buch.com"');
+ });
+
+ it('should transform ./relative links to current path', () => {
+ const input = `---
+title: Test
+---
+
+See [local file](./diagram.svg) for illustration.
+`;
+ const parser = new JekyllMarkdownParser(baseUrl, linkBasePath);
+ const result = parser.parse(input);
+
+ expect(result.html).toContain('href="/blog/my-post/diagram.svg"');
+ });
+
+ it('should transform multiple anchor links in TOC', () => {
+ const input = `---
+title: Test
+---
+
+## Inhalt
+
+- [Einleitung](#einleitung)
+- [Hauptteil](#hauptteil)
+- [Fazit](#fazit)
+`;
+ const parser = new JekyllMarkdownParser(baseUrl, linkBasePath);
+ const result = parser.parse(input);
+
+ expect(result.html).toContain('href="/blog/my-post#einleitung"');
+ expect(result.html).toContain('href="/blog/my-post#hauptteil"');
+ expect(result.html).toContain('href="/blog/my-post#fazit"');
+ });
+
+ it('should handle raw HTML anchor tags with relative hrefs', () => {
+ const input = `---
+title: Test
+---
+
+Jump to section
+`;
+ const parser = new JekyllMarkdownParser(baseUrl, linkBasePath);
+ const result = parser.parse(input);
+
+ expect(result.html).toContain('href="/blog/my-post#section"');
+ });
+
+ it('should preserve other attributes on anchor tags', () => {
+ const input = `---
+title: Test
+---
+
+Section
+`;
+ const parser = new JekyllMarkdownParser(baseUrl, linkBasePath);
+ const result = parser.parse(input);
+
+ expect(result.html).toContain('href="/blog/my-post#section"');
+ expect(result.html).toContain('class="nav-link"');
+ expect(result.html).toContain('id="toc-1"');
+ });
+
+ it('should work with material paths', () => {
+ const materialLinkPath = '/material/signal-forms';
+ const input = `---
+title: Test
+---
+
+See [other material](../other-material#section) for more.
+`;
+ const parser = new JekyllMarkdownParser(baseUrl, materialLinkPath);
+ const result = parser.parse(input);
+
+ expect(result.html).toContain('href="/material/other-material#section"');
+ });
+
+ it('should handle deeply nested relative paths', () => {
+ const input = `---
+title: Test
+---
+
+See [root](../../other) for more.
+`;
+ const parser = new JekyllMarkdownParser(baseUrl, linkBasePath);
+ const result = parser.parse(input);
+
+ expect(result.html).toContain('href="/other"');
+ });
+
+ it('should NOT transform links inside code blocks', () => {
+ const input = `---
+title: Test
+---
+
+\`\`\`html
+Link in code
+\`\`\`
+`;
+ const parser = new JekyllMarkdownParser(baseUrl, linkBasePath);
+ const result = parser.parse(input);
+
+ // Code is escaped by highlight.js, so the link should not be transformed
+ // The important assertion: no transformed href in the output
+ expect(result.html).toContain('language-html');
+ expect(result.html).not.toContain('href="/blog/my-post#section"');
+ });
+ });
+
describe('baseUrl edge cases', () => {
it('should work correctly when baseUrl has no trailing slash', () => {
const baseUrlNoSlash = 'https://example.com/blog/my-post';
@@ -1049,7 +1329,7 @@ title: Test

`;
- const parser = new JekyllMarkdownParser(baseUrlNoSlash);
+ const parser = new JekyllMarkdownParser(baseUrlNoSlash, linkBasePath);
const result = parser.parse(input);
// Without trailing slash, path gets concatenated directly
@@ -1065,11 +1345,288 @@ title: Test

`;
- const parser = new JekyllMarkdownParser(baseUrlWithSlash);
+ const parser = new JekyllMarkdownParser(baseUrlWithSlash, linkBasePath);
const result = parser.parse(input);
expect(result.html).toContain('src="https://example.com/blog/my-post/image.png"');
});
});
+
+ describe('Table of Contents (TOC) generation', () => {
+ it('should replace ${TOC_MARKER} marker with generated TOC', () => {
+ const input = `---
+title: Test
+---
+
+## Inhalt
+
+${TOC_MARKER}
+
+## Einleitung
+
+Text.
+
+## Fazit
+
+End.
+`;
+ const parser = new JekyllMarkdownParser(baseUrl, linkBasePath);
+ const result = parser.parse(input);
+
+ // TOC should contain links to headings after the marker
+ expect(result.html).toContain('href="/blog/my-post#einleitung"');
+ expect(result.html).toContain('href="/blog/my-post#fazit"');
+ // Should NOT contain the raw marker
+ expect(result.html).not.toContain('${TOC_MARKER}');
+ });
+
+ it('should skip headings before ${TOC_MARKER} marker', () => {
+ const input = `---
+title: Test
+---
+
+## Inhalt
+
+${TOC_MARKER}
+
+## Hauptteil
+
+Text.
+`;
+ const parser = new JekyllMarkdownParser(baseUrl, linkBasePath);
+ const result = parser.parse(input);
+
+ // "Inhalt" heading should NOT be in the TOC links
+ expect(result.html).not.toContain('>Inhalt');
+ // But "Hauptteil" should be in TOC
+ expect(result.html).toContain('href="/blog/my-post#hauptteil"');
+ });
+
+ it('should include h2 and h3 headings with proper nesting', () => {
+ const input = `---
+title: Test
+---
+
+## Inhalt
+
+${TOC_MARKER}
+
+## Kapitel 1
+
+Text.
+
+### Unterkapitel 1.1
+
+More text.
+
+## Kapitel 2
+
+End.
+`;
+ const parser = new JekyllMarkdownParser(baseUrl, linkBasePath);
+ const result = parser.parse(input);
+
+ expect(result.html).toContain('href="/blog/my-post#kapitel-1"');
+ expect(result.html).toContain('href="/blog/my-post#unterkapitel-11"');
+ expect(result.html).toContain('href="/blog/my-post#kapitel-2"');
+ });
+
+ it('should handle special characters in headings', () => {
+ const input = `---
+title: Test
+---
+
+## Inhalt
+
+${TOC_MARKER}
+
+## FAQ & Hilfe
+
+Text.
+
+## Über uns
+
+More.
+`;
+ const parser = new JekyllMarkdownParser(baseUrl, linkBasePath);
+ const result = parser.parse(input);
+
+ expect(result.html).toContain('href="/blog/my-post#faq--hilfe"');
+ // Note: marked URL-encodes non-ASCII chars in hrefs, but browser handles both
+ expect(result.html).toContain('href="/blog/my-post#%C3%BCber-uns"');
+ // The link text should contain the original characters (HTML-escaped)
+ expect(result.html).toContain('>FAQ & Hilfe');
+ expect(result.html).toContain('>Über uns');
+ });
+
+ it('should work without ${TOC_MARKER} marker (no changes)', () => {
+ const input = `---
+title: Test
+---
+
+## Heading
+
+Text.
+`;
+ const parser = new JekyllMarkdownParser(baseUrl, linkBasePath);
+ const result = parser.parse(input);
+
+ expect(result.html).toContain('
Heading
');
+ expect(result.html).not.toContain('${TOC_MARKER}');
+ });
+
+ it('should generate empty TOC when no headings after marker', () => {
+ const input = `---
+title: Test
+---
+
+## Inhalt
+
+${TOC_MARKER}
+
+Just text, no more headings.
+`;
+ const parser = new JekyllMarkdownParser(baseUrl, linkBasePath);
+ const result = parser.parse(input);
+
+ // Should not contain the marker
+ expect(result.html).not.toContain('${TOC_MARKER}');
+ // TOC area should be essentially empty (just the Inhalt heading)
+ expect(result.html).toContain('
Inhalt
');
+ });
+
+ it('should preserve inline code formatting in TOC links', () => {
+ const input = `---
+title: Test
+---
+
+## Inhalt
+
+${TOC_MARKER}
+
+## Using \`npm install\`
+
+Text.
+
+## The \`async\` Keyword
+
+More text.
+`;
+ const parser = new JekyllMarkdownParser(baseUrl, linkBasePath);
+ const result = parser.parse(input);
+
+ // TOC links should contain tags (rendered from markdown)
+ expect(result.html).toContain('npm install');
+ expect(result.html).toContain('async');
+ // The actual headings should also have code formatting
+ expect(result.html).toContain('