@leadertechie/md2html 0.1.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,76 @@
1
+ name: CI / Publish
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - '**'
7
+ pull_request:
8
+ workflow_dispatch:
9
+
10
+ jobs:
11
+ build-and-test:
12
+ runs-on: ubuntu-latest
13
+ permissions:
14
+ contents: read
15
+
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+
19
+ - uses: actions/setup-node@v4
20
+ with:
21
+ node-version: '20'
22
+
23
+ - name: Install dependencies
24
+ run: npm install
25
+
26
+ - name: Run tests
27
+ run: npm test
28
+
29
+ - name: Build package
30
+ run: npm run build
31
+
32
+ publish:
33
+ needs: build-and-test
34
+ runs-on: ubuntu-latest
35
+ if: github.ref == 'refs/heads/main' && github.event_name == 'push'
36
+ permissions:
37
+ contents: read
38
+
39
+ steps:
40
+ - uses: actions/checkout@v4
41
+ with:
42
+ fetch-depth: 0
43
+
44
+ - name: Setup GitVersion
45
+ uses: gittools/actions/gitversion/setup@v0.10.2
46
+ with:
47
+ versionSpec: '5.x'
48
+
49
+ - name: Execute GitVersion
50
+ id: gitversion
51
+ uses: gittools/actions/gitversion/execute@v0.10.2
52
+ with:
53
+ useConfigFile: true
54
+
55
+ - name: Display GitVersion outputs
56
+ run: |
57
+ echo "FullSemVer: ${{ steps.gitversion.outputs.fullSemVer }}"
58
+
59
+ - uses: actions/setup-node@v4
60
+ with:
61
+ node-version: '20'
62
+ registry-url: 'https://registry.npmjs.org/'
63
+
64
+ - name: Install dependencies
65
+ run: npm install
66
+
67
+ - name: Build package
68
+ run: npm run build
69
+
70
+ - name: Update package version
71
+ run: npm version ${{ steps.gitversion.outputs.fullSemVer }} --no-git-tag-version
72
+
73
+ - name: Publish to npm
74
+ run: npm publish --access public
75
+ env:
76
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
package/GitVersion.yml ADDED
@@ -0,0 +1,7 @@
1
+ next-version: 0.0.1
2
+ mode: Mainline
3
+ branches:
4
+ main:
5
+ regex: ^main$
6
+ increment: Patch
7
+ tag: alpha
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 leadertechie
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,73 @@
1
+ # @leadertechie/md2html
2
+
3
+ A configuration-driven markdown to HTML pipeline that parses markdown to an AST (ContentNode), then renders to HTML strings or Lit templates.
4
+
5
+ ## Features
6
+
7
+ - **Parse markdown to AST** - Converts markdown to a structured JSON AST (ContentNode[])
8
+ - **Render to HTML string** - Convert AST to plain HTML strings
9
+ - **Render to Lit templates** - Convert AST to Lit TemplateResult for web components
10
+ - **Configuration-driven** - No hardcoded paths or content structure
11
+ - **SSR-ready** - Works in both Node.js and browser environments
12
+ - **Image path handling** - Configurable prefix and base URL for images
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ npm install @leadertechie/md2html
18
+ ```
19
+
20
+ ## Usage
21
+
22
+ ### Basic Usage
23
+
24
+ ```typescript
25
+ import { MarkdownPipeline } from '@leadertechie/md2html';
26
+
27
+ const pipeline = new MarkdownPipeline();
28
+
29
+ const markdown = `# Hello World
30
+
31
+ This is a paragraph with **bold** and *italic* text.
32
+
33
+ - Item 1
34
+ - Item 2
35
+
36
+ ![Alt text](image.jpg)
37
+ `;
38
+
39
+ // Parse markdown to AST
40
+ const ast = pipeline.parse(markdown);
41
+
42
+ // Render AST to HTML string
43
+ const html = pipeline.render(ast);
44
+ ```
45
+
46
+ ### Configuration
47
+
48
+ ```typescript
49
+ import { MarkdownPipeline } from '@leadertechie/md2html';
50
+
51
+ const pipeline = new MarkdownPipeline({
52
+ imagePathPrefix: 'images/',
53
+ imageBaseUrl: 'https://cdn.example.com',
54
+ parseOptions: {
55
+ gfm: true,
56
+ breaks: false,
57
+ pedantic: false
58
+ }
59
+ });
60
+ ```
61
+
62
+ ### API
63
+
64
+ | Method | Description |
65
+ |--------|-------------|
66
+ | `parse(markdown)` | Parse markdown string to AST |
67
+ | `render(nodes)` | Render AST to HTML string |
68
+ | `renderMarkdown(markdown)` | Parse and render in one call |
69
+ | `renderPage(title, nodes)` | Render AST to full HTML page |
70
+
71
+ ## License
72
+
73
+ MIT
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=index.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.test.d.ts","sourceRoot":"","sources":["index.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,130 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { MarkdownParser } from '../src/parser';
3
+ import { HTMLRenderer } from '../src/renderer';
4
+ import { MarkdownPipeline } from '../src/pipeline';
5
+ describe('MarkdownParser', () => {
6
+ let parser;
7
+ beforeEach(() => {
8
+ parser = new MarkdownParser();
9
+ });
10
+ describe('parse', () => {
11
+ it('should parse heading', () => {
12
+ const result = parser.parse('# Hello World');
13
+ expect(result.content).toHaveLength(1);
14
+ expect(result.content[0].type).toBe('heading');
15
+ expect(result.content[0].content).toBe('Hello World');
16
+ });
17
+ it('should parse multiple headings', () => {
18
+ const result = parser.parse('# Heading 1\n## Heading 2\n### Heading 3');
19
+ expect(result.content).toHaveLength(3);
20
+ expect(result.content[0].type).toBe('heading');
21
+ });
22
+ it('should parse paragraph', () => {
23
+ const result = parser.parse('This is a paragraph');
24
+ expect(result.content).toHaveLength(1);
25
+ expect(result.content[0].type).toBe('paragraph');
26
+ });
27
+ it('should parse list items', () => {
28
+ const result = parser.parse('- Item 1\n- Item 2\n- Item 3');
29
+ expect(result.content).toHaveLength(1);
30
+ expect(result.content[0].type).toBe('list');
31
+ expect(result.content[0].children).toHaveLength(3);
32
+ });
33
+ it('should parse image in paragraph', () => {
34
+ const result = parser.parse('![Alt text](image.jpg)');
35
+ expect(result.content).toHaveLength(1);
36
+ expect(result.content[0].type).toBe('paragraph');
37
+ expect(result.content[0].children?.[0].type).toBe('image');
38
+ });
39
+ it('should parse code block', () => {
40
+ const result = parser.parse('```javascript\nconst x = 1;\n```');
41
+ expect(result.content).toHaveLength(1);
42
+ expect(result.content[0].type).toBe('code');
43
+ });
44
+ it('should handle image path prefix', () => {
45
+ const parserWithPrefix = new MarkdownParser({ imagePathPrefix: 'images/' });
46
+ const result = parserWithPrefix.parse('![Alt](photo.jpg)');
47
+ expect(result.content[0].children?.[0].src).toBe('images/photo.jpg');
48
+ });
49
+ it('should handle imageBaseUrl', () => {
50
+ const parserWithBaseUrl = new MarkdownParser({ imageBaseUrl: 'https://cdn.example.com/' });
51
+ const result = parserWithBaseUrl.parse('![Alt](photo.jpg)');
52
+ expect(result.content[0].children?.[0].src).toBe('https://cdn.example.com/photo.jpg');
53
+ });
54
+ });
55
+ });
56
+ describe('HTMLRenderer', () => {
57
+ let renderer;
58
+ beforeEach(() => {
59
+ renderer = new HTMLRenderer();
60
+ });
61
+ describe('renderNode', () => {
62
+ it('should render heading', () => {
63
+ const node = { type: 'heading', content: 'Hello', attributes: { level: '1' } };
64
+ const html = renderer.renderNode(node);
65
+ expect(html).toBe('<h1>Hello</h1>');
66
+ });
67
+ it('should render paragraph', () => {
68
+ const node = { type: 'paragraph', content: 'Hello world' };
69
+ const html = renderer.renderNode(node);
70
+ expect(html).toBe('<p>Hello world</p>');
71
+ });
72
+ it('should render image', () => {
73
+ const node = { type: 'image', src: 'image.jpg', alt: 'Alt text' };
74
+ const html = renderer.renderNode(node);
75
+ expect(html).toBe('<img src="image.jpg" alt="Alt text" class="">');
76
+ });
77
+ });
78
+ describe('renderNodes', () => {
79
+ it('should render multiple nodes', () => {
80
+ const nodes = [
81
+ { type: 'heading', content: 'Title', attributes: { level: '1' } },
82
+ { type: 'paragraph', content: 'Content' }
83
+ ];
84
+ const html = renderer.renderNodes(nodes);
85
+ expect(html).toContain('<h1>Title</h1>');
86
+ expect(html).toContain('<p>Content</p>');
87
+ });
88
+ it('should handle empty array', () => {
89
+ const html = renderer.renderNodes([]);
90
+ expect(html).toBe('');
91
+ });
92
+ });
93
+ });
94
+ describe('MarkdownPipeline', () => {
95
+ let pipeline;
96
+ beforeEach(() => {
97
+ pipeline = new MarkdownPipeline();
98
+ });
99
+ describe('parse', () => {
100
+ it('should parse markdown to nodes', () => {
101
+ const nodes = pipeline.parse('# Hello');
102
+ expect(nodes).toHaveLength(1);
103
+ expect(nodes[0].type).toBe('heading');
104
+ });
105
+ });
106
+ describe('renderMarkdown', () => {
107
+ it('should parse and render in one call', () => {
108
+ const html = pipeline.renderMarkdown('# Hello World\n\nThis is a paragraph.');
109
+ expect(html).toContain('<h1>Hello World</h1>');
110
+ expect(html).toContain('<p>This is a paragraph.</p>');
111
+ });
112
+ });
113
+ describe('renderPage', () => {
114
+ it('should render full HTML page', () => {
115
+ const nodes = [
116
+ { type: 'heading', content: 'Title', attributes: { level: '1' } }
117
+ ];
118
+ const page = pipeline.renderPage('My Page', nodes);
119
+ expect(page).toContain('<!DOCTYPE html>');
120
+ expect(page).toContain('<title>My Page</title>');
121
+ });
122
+ });
123
+ describe('configuration', () => {
124
+ it('should use imagePathPrefix config', () => {
125
+ const pipelineWithConfig = new MarkdownPipeline({ imagePathPrefix: 'images/' });
126
+ const nodes = pipelineWithConfig.parse('![img](test.jpg)');
127
+ expect(nodes[0].children?.[0].src).toBe('images/test.jpg');
128
+ });
129
+ });
130
+ });
@@ -0,0 +1,155 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { MarkdownParser } from '../src/parser';
3
+ import { HTMLRenderer } from '../src/renderer';
4
+ import { LitRenderer } from '../src/lit-renderer';
5
+ import { MarkdownPipeline } from '../src/pipeline';
6
+ import type { ContentNode } from '../src/types';
7
+
8
+ describe('MarkdownParser', () => {
9
+ let parser: MarkdownParser;
10
+
11
+ beforeEach(() => {
12
+ parser = new MarkdownParser();
13
+ });
14
+
15
+ describe('parse', () => {
16
+ it('should parse heading', () => {
17
+ const result = parser.parse('# Hello World');
18
+ expect(result.content).toHaveLength(1);
19
+ expect(result.content[0].type).toBe('heading');
20
+ expect(result.content[0].content).toBe('Hello World');
21
+ });
22
+
23
+ it('should parse multiple headings', () => {
24
+ const result = parser.parse('# Heading 1\n## Heading 2\n### Heading 3');
25
+ expect(result.content).toHaveLength(3);
26
+ expect(result.content[0].type).toBe('heading');
27
+ });
28
+
29
+ it('should parse paragraph', () => {
30
+ const result = parser.parse('This is a paragraph');
31
+ expect(result.content).toHaveLength(1);
32
+ expect(result.content[0].type).toBe('paragraph');
33
+ });
34
+
35
+ it('should parse list items', () => {
36
+ const result = parser.parse('- Item 1\n- Item 2\n- Item 3');
37
+ expect(result.content).toHaveLength(1);
38
+ expect(result.content[0].type).toBe('list');
39
+ expect(result.content[0].children).toHaveLength(3);
40
+ });
41
+
42
+ it('should parse image in paragraph', () => {
43
+ const result = parser.parse('![Alt text](image.jpg)');
44
+ expect(result.content).toHaveLength(1);
45
+ expect(result.content[0].type).toBe('paragraph');
46
+ expect(result.content[0].children?.[0].type).toBe('image');
47
+ });
48
+
49
+ it('should parse code block', () => {
50
+ const result = parser.parse('```javascript\nconst x = 1;\n```');
51
+ expect(result.content).toHaveLength(1);
52
+ expect(result.content[0].type).toBe('code');
53
+ });
54
+
55
+ it('should handle image path prefix', () => {
56
+ const parserWithPrefix = new MarkdownParser({ imagePathPrefix: 'images/' });
57
+ const result = parserWithPrefix.parse('![Alt](photo.jpg)');
58
+ expect(result.content[0].children?.[0].src).toBe('images/photo.jpg');
59
+ });
60
+
61
+ it('should handle imageBaseUrl', () => {
62
+ const parserWithBaseUrl = new MarkdownParser({ imageBaseUrl: 'https://cdn.example.com/' });
63
+ const result = parserWithBaseUrl.parse('![Alt](photo.jpg)');
64
+ expect(result.content[0].children?.[0].src).toBe('https://cdn.example.com/photo.jpg');
65
+ });
66
+ });
67
+ });
68
+
69
+ describe('HTMLRenderer', () => {
70
+ let renderer: HTMLRenderer;
71
+
72
+ beforeEach(() => {
73
+ renderer = new HTMLRenderer();
74
+ });
75
+
76
+ describe('renderNode', () => {
77
+ it('should render heading', () => {
78
+ const node: ContentNode = { type: 'heading', content: 'Hello', attributes: { level: '1' } };
79
+ const html = renderer.renderNode(node);
80
+ expect(html).toBe('<h1>Hello</h1>');
81
+ });
82
+
83
+ it('should render paragraph', () => {
84
+ const node: ContentNode = { type: 'paragraph', content: 'Hello world' };
85
+ const html = renderer.renderNode(node);
86
+ expect(html).toBe('<p>Hello world</p>');
87
+ });
88
+
89
+ it('should render image', () => {
90
+ const node: ContentNode = { type: 'image', src: 'image.jpg', alt: 'Alt text' };
91
+ const html = renderer.renderNode(node);
92
+ expect(html).toBe('<img src="image.jpg" alt="Alt text" class="">');
93
+ });
94
+ });
95
+
96
+ describe('renderNodes', () => {
97
+ it('should render multiple nodes', () => {
98
+ const nodes: ContentNode[] = [
99
+ { type: 'heading', content: 'Title', attributes: { level: '1' } },
100
+ { type: 'paragraph', content: 'Content' }
101
+ ];
102
+ const html = renderer.renderNodes(nodes);
103
+ expect(html).toContain('<h1>Title</h1>');
104
+ expect(html).toContain('<p>Content</p>');
105
+ });
106
+
107
+ it('should handle empty array', () => {
108
+ const html = renderer.renderNodes([]);
109
+ expect(html).toBe('');
110
+ });
111
+ });
112
+ });
113
+
114
+ describe('MarkdownPipeline', () => {
115
+ let pipeline: MarkdownPipeline;
116
+
117
+ beforeEach(() => {
118
+ pipeline = new MarkdownPipeline();
119
+ });
120
+
121
+ describe('parse', () => {
122
+ it('should parse markdown to nodes', () => {
123
+ const nodes = pipeline.parse('# Hello');
124
+ expect(nodes).toHaveLength(1);
125
+ expect(nodes[0].type).toBe('heading');
126
+ });
127
+ });
128
+
129
+ describe('renderMarkdown', () => {
130
+ it('should parse and render in one call', () => {
131
+ const html = pipeline.renderMarkdown('# Hello World\n\nThis is a paragraph.');
132
+ expect(html).toContain('<h1>Hello World</h1>');
133
+ expect(html).toContain('<p>This is a paragraph.</p>');
134
+ });
135
+ });
136
+
137
+ describe('renderPage', () => {
138
+ it('should render full HTML page', () => {
139
+ const nodes: ContentNode[] = [
140
+ { type: 'heading', content: 'Title', attributes: { level: '1' } }
141
+ ];
142
+ const page = pipeline.renderPage('My Page', nodes);
143
+ expect(page).toContain('<!DOCTYPE html>');
144
+ expect(page).toContain('<title>My Page</title>');
145
+ });
146
+ });
147
+
148
+ describe('configuration', () => {
149
+ it('should use imagePathPrefix config', () => {
150
+ const pipelineWithConfig = new MarkdownPipeline({ imagePathPrefix: 'images/' });
151
+ const nodes = pipelineWithConfig.parse('![img](test.jpg)');
152
+ expect(nodes[0].children?.[0].src).toBe('images/test.jpg');
153
+ });
154
+ });
155
+ });
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@leadertechie/md2html",
3
+ "version": "0.1.0-alpha.0",
4
+ "description": "Markdown to HTML pipeline - parse markdown to AST, render to HTML or Lit templates",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js",
12
+ "default": "./dist/index.js"
13
+ }
14
+ },
15
+ "scripts": {
16
+ "build": "tsc",
17
+ "test": "vitest run"
18
+ },
19
+ "dependencies": {
20
+ "marked": "^15.0.0"
21
+ },
22
+ "peerDependencies": {
23
+ "lit": "^3.0.0"
24
+ },
25
+ "devDependencies": {
26
+ "@types/node": "^20.0.0",
27
+ "typescript": "^5.0.0",
28
+ "vitest": "^2.0.0"
29
+ },
30
+ "keywords": [
31
+ "markdown",
32
+ "html",
33
+ "ast",
34
+ "parser",
35
+ "renderer",
36
+ "ssr"
37
+ ],
38
+ "license": "MIT",
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "git+https://github.com/leadertechie/md2html.git"
42
+ }
43
+ }
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ export * from './types';
2
+ export * from './parser';
3
+ export * from './renderer';
4
+ export * from './lit-renderer';
5
+ export * from './pipeline';
6
+
7
+ export { LitRenderer as HTMLRenderer } from './lit-renderer';
@@ -0,0 +1,91 @@
1
+ import { TemplateResult, html } from 'lit';
2
+ import { unsafeHTML } from 'lit/directives/unsafe-html.js';
3
+ import { ContentNode } from './types';
4
+
5
+ export class LitRenderer {
6
+ private renderTextNode(node: ContentNode): TemplateResult {
7
+ if (node.type === 'image') {
8
+ return html`<img src="${node.src}" alt="${node.alt || ''}" class="inline-image" style="max-width:100%;height:auto;">`;
9
+ }
10
+ return html`${unsafeHTML(node.content || '')}`;
11
+ }
12
+
13
+ renderNode(node: ContentNode): TemplateResult {
14
+ switch (node.type) {
15
+ case 'heading': {
16
+ const level = node.attributes?.level || '2';
17
+ if (level === '1') return html`<h1>${unsafeHTML(node.content)}</h1>`;
18
+ if (level === '2') return html`<h2>${unsafeHTML(node.content)}</h2>`;
19
+ if (level === '3') return html`<h3>${unsafeHTML(node.content)}</h3>`;
20
+ return html`<h2>${unsafeHTML(node.content)}</h2>`;
21
+ }
22
+
23
+ case 'paragraph':
24
+ if (node.children) {
25
+ return html`<p>${node.children.map(child => this.renderTextNode(child))}</p>`;
26
+ }
27
+ return html`<p>${unsafeHTML(node.content)}</p>`;
28
+
29
+ case 'list':
30
+ return html`<ul>${node.children?.map(child => this.renderNode(child))}</ul>`;
31
+
32
+ case 'list-item':
33
+ return html`<li>${unsafeHTML(node.content)}</li>`;
34
+
35
+ case 'image':
36
+ return html`<img src="${node.src || node.attributes?.src}" alt="${node.alt || node.attributes?.alt || ''}" class="${node.className || ''}" style="max-width:100%;height:auto;">`;
37
+
38
+ case 'container':
39
+ return html`<div class="${node.className || ''}" style="${node.attributes?.style || ''}">
40
+ ${node.children?.map(child => this.renderNode(child))}
41
+ </div>`;
42
+
43
+ case 'code':
44
+ return html`<pre><code class="language-${node.attributes?.lang || ''}">${node.content || ''}</code></pre>`;
45
+
46
+ case 'text':
47
+ return html`${node.content}`;
48
+
49
+ default:
50
+ return html``;
51
+ }
52
+ }
53
+
54
+ renderNodes(nodes: ContentNode[]): TemplateResult {
55
+ if (!nodes || nodes.length === 0) {
56
+ return html``;
57
+ }
58
+ return html`${nodes.map(node => this.renderNode(node))}`;
59
+ }
60
+
61
+ renderToHTMLString(nodes: ContentNode[]): string {
62
+ if (!nodes || nodes.length === 0) {
63
+ return '';
64
+ }
65
+ return nodes.map(node => this.nodeToHTMLString(node)).join('\n');
66
+ }
67
+
68
+ private nodeToHTMLString(node: ContentNode): string {
69
+ switch (node.type) {
70
+ case 'heading':
71
+ const level = node.attributes?.level || '2';
72
+ return `<h${level}>${node.content}</h${level}>`;
73
+ case 'paragraph':
74
+ return `<p>${node.content}</p>`;
75
+ case 'list':
76
+ const items = node.children?.map(child => this.nodeToHTMLString(child)).join('') || '';
77
+ return `<ul>${items}</ul>`;
78
+ case 'list-item':
79
+ return `<li>${node.content}</li>`;
80
+ case 'image':
81
+ return `<img src="${node.attributes?.src}" alt="${node.attributes?.alt}" class="${node.className || ''}">`;
82
+ case 'container':
83
+ const childrenHTML = node.children?.map(child => this.nodeToHTMLString(child)).join('') || '';
84
+ return `<div class="${node.className || ''}">${childrenHTML}</div>`;
85
+ case 'text':
86
+ return node.content || '';
87
+ default:
88
+ return '';
89
+ }
90
+ }
91
+ }
package/src/parser.ts ADDED
@@ -0,0 +1,142 @@
1
+ import { marked } from 'marked';
2
+ import { ContentNode, MarkdownContent, ParseOptions } from './types';
3
+
4
+ export class MarkdownParser {
5
+ private imagePathPrefix: string;
6
+ private imageBaseUrl: string;
7
+
8
+ constructor(options?: { imagePathPrefix?: string; imageBaseUrl?: string }) {
9
+ this.imagePathPrefix = options?.imagePathPrefix || '';
10
+ this.imageBaseUrl = options?.imageBaseUrl || '';
11
+ }
12
+
13
+ private processImagePath(src: string): string {
14
+ if (src.startsWith('http') || src.startsWith('/')) {
15
+ return src;
16
+ }
17
+ let path = this.imagePathPrefix ? `${this.imagePathPrefix}${src}` : src;
18
+ if (this.imageBaseUrl && !path.startsWith('http')) {
19
+ path = `${this.imageBaseUrl}${path}`;
20
+ }
21
+ return path;
22
+ }
23
+
24
+ private processInlineFormatting(text: string): string {
25
+ return text
26
+ .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
27
+ .replace(/\*(.+?)\*/g, '<em>$1</em>');
28
+ }
29
+
30
+ private parseTokens(tokens: unknown[]): ContentNode[] {
31
+ const nodes: ContentNode[] = [];
32
+
33
+ for (const token of tokens) {
34
+ const node = this.parseToken(token as Record<string, unknown>);
35
+ if (node) {
36
+ nodes.push(node);
37
+ }
38
+ }
39
+
40
+ return nodes;
41
+ }
42
+
43
+ private parseToken(token: Record<string, unknown>): ContentNode | null {
44
+ switch (token.type) {
45
+ case 'heading':
46
+ return {
47
+ type: 'heading',
48
+ content: token.text as string,
49
+ attributes: { level: String(token.depth) }
50
+ };
51
+
52
+ case 'paragraph':
53
+ const tokens = (token.tokens as Array<Record<string, unknown>>) || [];
54
+ const hasInlineImage = tokens.some(t => t.type === 'image');
55
+
56
+ if (hasInlineImage) {
57
+ const children = tokens.map(t => {
58
+ if (t.type === 'image') {
59
+ return {
60
+ type: 'image' as const,
61
+ src: this.processImagePath(t.href as string),
62
+ alt: t.text as string || ''
63
+ };
64
+ }
65
+ return {
66
+ type: 'text' as const,
67
+ content: this.processInlineFormatting(t.text as string || '')
68
+ };
69
+ });
70
+ return {
71
+ type: 'paragraph',
72
+ children
73
+ };
74
+ }
75
+
76
+ return {
77
+ type: 'paragraph',
78
+ content: this.processInlineFormatting(token.text as string)
79
+ };
80
+
81
+ case 'list':
82
+ return {
83
+ type: 'list',
84
+ ordered: token.ordered as boolean,
85
+ children: (token.items as Array<Record<string, unknown>>).map((item) => ({
86
+ type: 'list-item',
87
+ content: this.processInlineFormatting(item.text as string)
88
+ }))
89
+ };
90
+
91
+ case 'image':
92
+ return {
93
+ type: 'image',
94
+ src: this.processImagePath(token.href as string),
95
+ alt: token.title as string || ''
96
+ };
97
+
98
+ case 'code':
99
+ return {
100
+ type: 'code',
101
+ content: token.text as string,
102
+ attributes: { lang: token.lang as string || '' }
103
+ };
104
+
105
+ case 'hr':
106
+ return { type: 'container', attributes: { tag: 'hr' } };
107
+
108
+ case 'blockquote':
109
+ return {
110
+ type: 'container',
111
+ attributes: { tag: 'blockquote' },
112
+ children: this.parseTokens((token as Record<string, unknown>).tokens as unknown[] || [])
113
+ };
114
+
115
+ case 'html':
116
+ return { type: 'container', content: token.raw as string };
117
+
118
+ default:
119
+ return null;
120
+ }
121
+ }
122
+
123
+ parse(markdown: string, options?: ParseOptions): MarkdownContent {
124
+ const parseOptions = {
125
+ gfm: options?.gfm ?? true,
126
+ breaks: options?.breaks ?? false,
127
+ pedantic: options?.pedantic ?? false
128
+ };
129
+
130
+ const tokens = marked.lexer(markdown, parseOptions as Parameters<typeof marked.lexer>[1]);
131
+ const content = this.parseTokens(tokens);
132
+
133
+ return {
134
+ title: '',
135
+ content
136
+ };
137
+ }
138
+
139
+ parseToNodes(markdown: string, options?: ParseOptions): ContentNode[] {
140
+ return this.parse(markdown, options).content;
141
+ }
142
+ }
@@ -0,0 +1,66 @@
1
+ import { MarkdownParser } from './parser';
2
+ import { HTMLRenderer } from './renderer';
3
+ import { ContentNode, MarkdownContent, PipelineConfig } from './types';
4
+
5
+ export class MarkdownPipeline {
6
+ private parser: MarkdownParser;
7
+ private renderer: HTMLRenderer;
8
+ private config: Required<PipelineConfig>;
9
+
10
+ constructor(config: PipelineConfig = {}) {
11
+ this.config = {
12
+ imagePathPrefix: config.imagePathPrefix || '',
13
+ imageBaseUrl: config.imageBaseUrl || '',
14
+ parseOptions: {
15
+ gfm: config.parseOptions?.gfm ?? true,
16
+ breaks: config.parseOptions?.breaks ?? false,
17
+ pedantic: config.parseOptions?.pedantic ?? false
18
+ }
19
+ };
20
+
21
+ this.parser = new MarkdownParser({
22
+ imagePathPrefix: this.config.imagePathPrefix,
23
+ imageBaseUrl: this.config.imageBaseUrl
24
+ });
25
+ this.renderer = new HTMLRenderer();
26
+ }
27
+
28
+ parse(markdown: string): ContentNode[] {
29
+ return this.parser.parseToNodes(markdown, this.config.parseOptions);
30
+ }
31
+
32
+ parseWithMetadata(markdown: string): MarkdownContent {
33
+ return this.parser.parse(markdown, this.config.parseOptions);
34
+ }
35
+
36
+ render(nodes: ContentNode[]): string {
37
+ return this.renderer.renderNodes(nodes);
38
+ }
39
+
40
+ renderMarkdown(markdown: string): string {
41
+ const nodes = this.parse(markdown);
42
+ return this.render(nodes);
43
+ }
44
+
45
+ renderPage(title: string, nodes: ContentNode[], options?: {
46
+ lang?: string;
47
+ charset?: string;
48
+ }): string {
49
+ const html = this.render(nodes);
50
+ return `<!DOCTYPE html>
51
+ <html lang="${options?.lang || 'en'}">
52
+ <head>
53
+ <meta charset="${options?.charset || 'UTF-8'}">
54
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
55
+ <title>${title}</title>
56
+ </head>
57
+ <body>
58
+ ${html}
59
+ </body>
60
+ </html>`;
61
+ }
62
+
63
+ getConfig(): Readonly<Required<PipelineConfig>> {
64
+ return { ...this.config };
65
+ }
66
+ }
@@ -0,0 +1,67 @@
1
+ import { ContentNode } from './types';
2
+
3
+ export class HTMLRenderer {
4
+ renderNode(node: ContentNode): string {
5
+ switch (node.type) {
6
+ case 'heading':
7
+ const level = node.attributes?.level || '2';
8
+ return `<h${level}>${node.content || ''}</h${level}>`;
9
+
10
+ case 'paragraph':
11
+ if (node.children) {
12
+ return `<p>${node.children.map(child => this.renderNode(child)).join('')}</p>`;
13
+ }
14
+ return `<p>${node.content || ''}</p>`;
15
+
16
+ case 'list':
17
+ const tag = node.ordered ? 'ol' : 'ul';
18
+ const items = node.children?.map(child => this.renderNode(child)).join('') || '';
19
+ return `<${tag}>${items}</${tag}>`;
20
+
21
+ case 'list-item':
22
+ return `<li>${node.content || ''}</li>`;
23
+
24
+ case 'image':
25
+ const src = node.src || node.attributes?.src || '';
26
+ const alt = node.alt || node.attributes?.alt || '';
27
+ return `<img src="${src}" alt="${alt}" class="${node.className || ''}">`;
28
+
29
+ case 'code':
30
+ return `<pre><code class="language-${node.attributes?.lang || ''}">${node.content || ''}</code></pre>`;
31
+
32
+ case 'container':
33
+ if (node.attributes?.tag === 'hr') return '<hr>';
34
+ if (node.attributes?.tag === 'blockquote') {
35
+ const children = node.children?.map(child => this.renderNode(child)).join('') || '';
36
+ return `<blockquote>${children}</blockquote>`;
37
+ }
38
+ const containerChildren = node.children?.map(child => this.renderNode(child)).join('') || '';
39
+ return `<div class="${node.className || ''}">${containerChildren}</div>`;
40
+
41
+ case 'strong':
42
+ return `<strong>${node.content || ''}</strong>`;
43
+
44
+ case 'emphasis':
45
+ return `<em>${node.content || ''}</em>`;
46
+
47
+ case 'text':
48
+ default:
49
+ return node.content || '';
50
+ }
51
+ }
52
+
53
+ renderNodes(nodes: ContentNode[]): string {
54
+ if (!nodes || nodes.length === 0) {
55
+ return '';
56
+ }
57
+ return nodes.map(node => this.renderNode(node)).join('\n');
58
+ }
59
+
60
+ renderToHTMLString(nodes: ContentNode[]): string {
61
+ return this.renderNodes(nodes);
62
+ }
63
+
64
+ render(markdown: string): string {
65
+ return markdown;
66
+ }
67
+ }
package/src/types.ts ADDED
@@ -0,0 +1,40 @@
1
+ export type ContentNodeType =
2
+ | 'text'
3
+ | 'heading'
4
+ | 'paragraph'
5
+ | 'list'
6
+ | 'list-item'
7
+ | 'image'
8
+ | 'code'
9
+ | 'container'
10
+ | 'strong'
11
+ | 'emphasis';
12
+
13
+ export interface ContentNode {
14
+ type: ContentNodeType;
15
+ content?: string;
16
+ children?: ContentNode[];
17
+ attributes?: Record<string, unknown>;
18
+ className?: string;
19
+ src?: string;
20
+ alt?: string;
21
+ ordered?: boolean;
22
+ }
23
+
24
+ export interface MarkdownContent {
25
+ title: string;
26
+ metadata?: Record<string, unknown>;
27
+ content: ContentNode[];
28
+ }
29
+
30
+ export interface ParseOptions {
31
+ gfm?: boolean;
32
+ breaks?: boolean;
33
+ pedantic?: boolean;
34
+ }
35
+
36
+ export interface PipelineConfig {
37
+ imagePathPrefix?: string;
38
+ imageBaseUrl?: string;
39
+ parseOptions?: ParseOptions;
40
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "ESNext",
5
+ "lib": ["ES2020"],
6
+ "moduleResolution": "bundler",
7
+ "strict": true,
8
+ "esModuleInterop": true,
9
+ "skipLibCheck": true,
10
+ "forceConsistentCasingInFileNames": true,
11
+ "declaration": true,
12
+ "declarationMap": true,
13
+ "outDir": "./dist",
14
+ "rootDir": "./src"
15
+ },
16
+ "include": ["src/**/*"],
17
+ "exclude": ["node_modules", "dist", "__tests__", "**/*.test.ts"]
18
+ }