@san-siva/blogkit-md 0.1.0 → 0.1.2
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.
- package/README.md +95 -47
- package/app/page.tsx +7 -2
- package/components/BlogPost.tsx +5 -4
- package/hooks/readMarkdownFile.ts +4 -3
- package/package.json +8 -4
- package/utils/groupSections.test.ts +97 -0
- package/utils/groupSections.ts +54 -98
- package/utils/parseMarkdown.ts +30 -4
- package/utils/renderMarkdown.tsx +88 -39
- package/website/package-lock.json +6 -3
package/README.md
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
|
|
1
|
+
---
|
|
2
|
+
title: blogkit-md
|
|
3
|
+
description: A Next.js tool that converts standard markdown files into rendered blog posts for `@san-siva/blogkit`.
|
|
4
|
+
---
|
|
2
5
|
|
|
3
6
|
A Next.js tool that converts standard markdown files into rendered blog posts for [`@san-siva/blogkit`](https://blogkit.santhoshsiva.dev).
|
|
4
7
|
|
|
5
|
-
>
|
|
8
|
+
> This README is automatically rendered as a live demo at [blogkit-md.santhoshsiva.dev](https://blogkit-md.santhoshsiva.dev)
|
|
6
9
|
|
|
7
10
|
## Getting started
|
|
8
11
|
|
|
@@ -87,25 +90,46 @@ export default function Page() {
|
|
|
87
90
|
| `filePath` | `string` | Yes | Path to the markdown file. Relative paths are resolved from `process.cwd()`. |
|
|
88
91
|
| `jsonLd` | `WithContext<Thing>` | No | Optional JSON-LD schema passed to `<Blog>` for structured data / SEO. |
|
|
89
92
|
|
|
93
|
+
### Frontmatter
|
|
94
|
+
|
|
95
|
+
Set the page title and description via a YAML frontmatter block at the top of your markdown file:
|
|
96
|
+
|
|
97
|
+
```yaml
|
|
98
|
+
---
|
|
99
|
+
title: My Post Title
|
|
100
|
+
description: A short description shown below the title
|
|
101
|
+
---
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
| Field | Description |
|
|
105
|
+
| :------------ | :-------------------------------------- |
|
|
106
|
+
| `title` | Renders as the `BlogHeader` page title |
|
|
107
|
+
| `description` | Renders as the `BlogHeader` description |
|
|
108
|
+
|
|
90
109
|
## Supported markdown features
|
|
91
110
|
|
|
92
|
-
| Feature
|
|
93
|
-
|
|
|
94
|
-
|
|
|
95
|
-
|
|
|
96
|
-
|
|
|
97
|
-
| Bold |
|
|
98
|
-
|
|
|
99
|
-
|
|
|
100
|
-
|
|
|
101
|
-
|
|
|
102
|
-
|
|
|
103
|
-
|
|
|
104
|
-
|
|
|
105
|
-
|
|
|
106
|
-
|
|
|
107
|
-
|
|
|
108
|
-
|
|
|
111
|
+
| Feature | Syntax |
|
|
112
|
+
| -------------------- | ---------------------------------------------- |
|
|
113
|
+
| Frontmatter | `---` YAML block — sets `title`, `description` |
|
|
114
|
+
| Section title | `# H1` `## H2` — top-level section |
|
|
115
|
+
| Subsection title | `### H3` — nested section |
|
|
116
|
+
| Bold line | `#### H4` `##### H5` `###### H6` |
|
|
117
|
+
| Paragraph | Plain text |
|
|
118
|
+
| Hard line break | Two spaces at end of line |
|
|
119
|
+
| Bold | `**bold**` |
|
|
120
|
+
| Italic | `_italic_` |
|
|
121
|
+
| Inline code | `` `code` `` |
|
|
122
|
+
| Link | `[text](url)` |
|
|
123
|
+
| Image | `` |
|
|
124
|
+
| Ordered list | `1. item` |
|
|
125
|
+
| Unordered list | `- item` |
|
|
126
|
+
| Table | GFM table syntax |
|
|
127
|
+
| Code block | ` ```lang ` |
|
|
128
|
+
| Mermaid diagram | ` ```mermaid ` |
|
|
129
|
+
| Thematic break | `---` |
|
|
130
|
+
| Blockquote | `> text` — renders as info callout |
|
|
131
|
+
| Blockquote (warning) | `> ~text` — renders as warning callout |
|
|
132
|
+
| Blockquote (error) | `> !text` — renders as error callout |
|
|
109
133
|
|
|
110
134
|
## Philosophy
|
|
111
135
|
|
|
@@ -141,36 +165,30 @@ flowchart LR
|
|
|
141
165
|
|
|
142
166
|
### Headings as Layout Triggers
|
|
143
167
|
|
|
144
|
-
In `blogkit-md`, headings aren't just for changing font sizes
|
|
168
|
+
In `blogkit-md`, headings aren't just for changing font sizes — **they are the architectural blueprint for your post**.
|
|
145
169
|
|
|
146
|
-
| Markdown
|
|
147
|
-
|
|
|
148
|
-
| `# H1`
|
|
149
|
-
|
|
|
150
|
-
|
|
|
151
|
-
| `#### H4` | **Section Break.** Renders as a bold line, but acts as a layout trigger: it forces the _next_ `H3` to break out and become a brand-new, top-level section. |
|
|
152
|
-
| `##### H5` & `###### H6` | **Inline Emphasis.** Renders as a bold line within the current section or subsection without altering the page layout. |
|
|
170
|
+
| Markdown | Layout Behavior |
|
|
171
|
+
| :------------------------------- | :------------------------------------------------------------------------------------------- |
|
|
172
|
+
| `# H1` & `## H2` | **Top-level section.** Creates a new `BlogSection`. |
|
|
173
|
+
| `### H3` | **Subsection.** Nests within the active H1/H2 section. Promoted to top-level if none exists. |
|
|
174
|
+
| `#### H4` `##### H5` `###### H6` | **Bold line.** Rendered as styled text inside the current section — no layout effect. |
|
|
153
175
|
|
|
154
|
-
> Standard content—
|
|
176
|
+
> Standard content — paragraphs, lists, code blocks — flows into the most recently opened section or subsection.
|
|
155
177
|
|
|
156
|
-
###
|
|
178
|
+
### The Nesting Logic
|
|
157
179
|
|
|
158
|
-
|
|
180
|
+
The layout is determined entirely by heading level (depth):
|
|
159
181
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
| `H1` loses structural significance if not at the top | If an `H1` appears anywhere other than the very top of the document, it does not create a page title. Instead, it is treated as stylized text and rendered as a section break. |
|
|
164
|
-
| Intro section | Any text written before the first heading—or directly beneath the `# H1` page title—is automatically grouped into an untitled, top-level BlogSection |
|
|
182
|
+
- **Deeper heading (level up):** If a heading has a higher number than the current one (e.g. `### H3` after `## H2`), it creates a nested subsection inside the current section.
|
|
183
|
+
- **Equal or shallower heading (level down):** If a heading has a number equal to or lower than the current one (e.g. `## H2` after another `## H2`), it closes the current section and starts a new one at the appropriate level.
|
|
184
|
+
- **Initial content:** Any content before the very first heading is grouped into an automatic untitled intro section.
|
|
165
185
|
|
|
166
186
|
### Visualizing the Structure
|
|
167
187
|
|
|
168
|
-
|
|
188
|
+
Here is how a standard markdown document maps to blog layout:
|
|
169
189
|
|
|
170
190
|
```markdown
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
This text becomes the Preamble (an untitled, top-level section).
|
|
191
|
+
Intro content
|
|
174
192
|
|
|
175
193
|
## The Setup
|
|
176
194
|
|
|
@@ -182,9 +200,17 @@ Nested content belongs here.
|
|
|
182
200
|
|
|
183
201
|
## The Execution
|
|
184
202
|
|
|
185
|
-
|
|
203
|
+
Some more content.
|
|
186
204
|
|
|
187
205
|
### The Results
|
|
206
|
+
|
|
207
|
+
Result content.
|
|
208
|
+
|
|
209
|
+
# A Note
|
|
210
|
+
|
|
211
|
+
### A Subsection
|
|
212
|
+
|
|
213
|
+
## Also Nested
|
|
188
214
|
```
|
|
189
215
|
|
|
190
216
|
Here is how the parser breaks the above document down into isolated React components:
|
|
@@ -192,7 +218,7 @@ Here is how the parser breaks the above document down into isolated React compon
|
|
|
192
218
|
##### Intro section
|
|
193
219
|
|
|
194
220
|
```markdown
|
|
195
|
-
|
|
221
|
+
Intro content
|
|
196
222
|
```
|
|
197
223
|
|
|
198
224
|
##### Section 1
|
|
@@ -202,7 +228,7 @@ This text becomes an introductory, untitled section.
|
|
|
202
228
|
|
|
203
229
|
Some content goes here.
|
|
204
230
|
|
|
205
|
-
|
|
231
|
+
### Prerequisites
|
|
206
232
|
|
|
207
233
|
Nested content belongs here.
|
|
208
234
|
```
|
|
@@ -212,25 +238,47 @@ Nested content belongs here.
|
|
|
212
238
|
```markdown
|
|
213
239
|
## The Execution
|
|
214
240
|
|
|
215
|
-
|
|
241
|
+
Some more content.
|
|
242
|
+
|
|
243
|
+
### The Results
|
|
244
|
+
|
|
245
|
+
Result content.
|
|
216
246
|
```
|
|
217
247
|
|
|
218
248
|
##### Section 3
|
|
219
249
|
|
|
220
250
|
```markdown
|
|
221
|
-
|
|
251
|
+
# A Note
|
|
252
|
+
|
|
253
|
+
### A Subsection
|
|
254
|
+
|
|
255
|
+
## Also Nested
|
|
222
256
|
```
|
|
223
257
|
|
|
258
|
+
> `## Also Nested` does not start a new top-level section. Because it appears after a `### H3` inside an `# H1`, the parser backtracks to the H1 and nests the H2 beneath it.
|
|
259
|
+
|
|
260
|
+
### Callouts
|
|
261
|
+
|
|
262
|
+
Blockquotes are rendered as styled callout banners. The callout type is controlled by a prefix on the first word of the quote:
|
|
263
|
+
|
|
264
|
+
| Syntax | Callout Type | Example |
|
|
265
|
+
| :-------- | :----------- | :--------------------------- |
|
|
266
|
+
| `> text` | Info | `> This is an info callout.` |
|
|
267
|
+
| `> ~text` | Warning | `> ~This is a warning.` |
|
|
268
|
+
| `> !text` | Error | `> !This is an error.` |
|
|
269
|
+
|
|
270
|
+
The prefix character is stripped from the rendered output — only the callout style changes.
|
|
271
|
+
|
|
224
272
|
## Want more customization?
|
|
225
273
|
|
|
226
274
|
`blogkit-md` is just one piece of the puzzle. If you want to customize the underlying React components, tweak the UI, or take full control over your blog's layout, dive into the official [Blogkit documentation](https://blogkit.santhoshsiva.dev/).
|
|
227
275
|
|
|
228
|
-
|
|
276
|
+
### License
|
|
229
277
|
|
|
230
|
-
`blogkit-md` is open source software licensed under the [MIT license](https://github.com/san-siva/blogkit-md/blob/main/LICENSE).
|
|
278
|
+
`blogkit-md` is open source software licensed under the [MIT license](https://github.com/san-siva/blogkit-md/blob/main/LICENSE).
|
|
231
279
|
Contributions are welcome!
|
|
232
280
|
|
|
233
|
-
|
|
281
|
+
### About
|
|
234
282
|
|
|
235
283
|
- **Author:** [Santhosh Siva](https://www.santhoshsiva.dev)
|
|
236
284
|
- **License:** [MIT](https://github.com/san-siva/blogkit-md/blob/main/LICENSE)
|
package/app/page.tsx
CHANGED
|
@@ -14,11 +14,16 @@ const Page = async () => {
|
|
|
14
14
|
);
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
const { rendered } = result;
|
|
17
|
+
const { rendered, frontmatter } = result;
|
|
18
18
|
|
|
19
19
|
return (
|
|
20
20
|
<Blog>
|
|
21
|
-
{
|
|
21
|
+
{frontmatter.title && (
|
|
22
|
+
<BlogHeader
|
|
23
|
+
title={[frontmatter.title]}
|
|
24
|
+
desc={frontmatter.description ? [frontmatter.description] : []}
|
|
25
|
+
/>
|
|
26
|
+
)}
|
|
22
27
|
<MarkdownSections rendered={rendered} />
|
|
23
28
|
</Blog>
|
|
24
29
|
);
|
package/components/BlogPost.tsx
CHANGED
|
@@ -38,14 +38,15 @@ const BlogPost = async ({ filePath, jsonLd }: BlogPostProperties) => {
|
|
|
38
38
|
);
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
const ast = parseMarkdown(content);
|
|
41
|
+
const { ast, frontmatter } = parseMarkdown(content);
|
|
42
42
|
const rendered = renderMarkdownAst(ast);
|
|
43
43
|
|
|
44
|
+
const title = frontmatter.title;
|
|
45
|
+
const desc = frontmatter.description;
|
|
46
|
+
|
|
44
47
|
return (
|
|
45
48
|
<Blog jsonLd={jsonLd}>
|
|
46
|
-
{
|
|
47
|
-
<BlogHeader title={[rendered.pageTitle]} desc={[]} />
|
|
48
|
-
)}
|
|
49
|
+
{title && <BlogHeader title={[title]} desc={desc ? [desc] : []} />}
|
|
49
50
|
<MarkdownSections rendered={rendered} />
|
|
50
51
|
</Blog>
|
|
51
52
|
);
|
|
@@ -2,12 +2,13 @@ import { readFile } from 'node:fs/promises';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
|
|
4
4
|
import '@/utils/devReloadTrigger';
|
|
5
|
+
import type { Frontmatter } from '@/utils/parseMarkdown';
|
|
5
6
|
import { parseMarkdown } from '@/utils/parseMarkdown';
|
|
6
7
|
import type { RenderedMarkdown } from '@/utils/renderMarkdown';
|
|
7
8
|
import { renderMarkdownAst } from '@/utils/renderMarkdown';
|
|
8
9
|
|
|
9
10
|
type MarkdownFileResult =
|
|
10
|
-
| { success: true; rendered: RenderedMarkdown }
|
|
11
|
+
| { success: true; rendered: RenderedMarkdown; frontmatter: Frontmatter }
|
|
11
12
|
| { success: false; error: string };
|
|
12
13
|
|
|
13
14
|
export const readMarkdownFile = async (
|
|
@@ -37,8 +38,8 @@ export const readMarkdownFile = async (
|
|
|
37
38
|
return { success: false, error: `File "${filePath}" is empty.` };
|
|
38
39
|
}
|
|
39
40
|
|
|
40
|
-
const ast = parseMarkdown(content);
|
|
41
|
+
const { ast, frontmatter } = parseMarkdown(content);
|
|
41
42
|
const rendered = renderMarkdownAst(ast);
|
|
42
43
|
|
|
43
|
-
return { success: true, rendered };
|
|
44
|
+
return { success: true, rendered, frontmatter };
|
|
44
45
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@san-siva/blogkit-md",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Converts markdown files into JSX blog posts for Blogkit",
|
|
5
5
|
"main": "index.ts",
|
|
6
6
|
"exports": {
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"dev": "tsx scripts/dev.ts",
|
|
11
11
|
"build": "MARKDOWN_FILE=data/test.md next build",
|
|
12
12
|
"start": "next start",
|
|
13
|
+
"test": "vitest run",
|
|
13
14
|
"lint": "eslint . --config eslint.config.ts",
|
|
14
15
|
"fix": "eslint . --config eslint.config.ts --fix"
|
|
15
16
|
},
|
|
@@ -19,11 +20,13 @@
|
|
|
19
20
|
"next": "^16.0.10",
|
|
20
21
|
"react": "^19.0.0",
|
|
21
22
|
"react-dom": "^19.0.0",
|
|
23
|
+
"remark-frontmatter": "^5.0.0",
|
|
22
24
|
"remark-gfm": "^4.0.1",
|
|
23
|
-
"schema-dts": "^1.1.2",
|
|
24
25
|
"remark-parse": "^11.0.0",
|
|
25
26
|
"sass": "^1.95.1",
|
|
26
|
-
"
|
|
27
|
+
"schema-dts": "^1.1.2",
|
|
28
|
+
"unified": "^11.0.5",
|
|
29
|
+
"yaml": "^2.8.2"
|
|
27
30
|
},
|
|
28
31
|
"devDependencies": {
|
|
29
32
|
"@eslint/js": "^9.0.0",
|
|
@@ -49,6 +52,7 @@
|
|
|
49
52
|
"prettier": "^3.0.0",
|
|
50
53
|
"tsx": "^4.0.0",
|
|
51
54
|
"typescript": "^5.0.0",
|
|
52
|
-
"typescript-eslint": "^8.0.0"
|
|
55
|
+
"typescript-eslint": "^8.0.0",
|
|
56
|
+
"vitest": "^4.1.0"
|
|
53
57
|
}
|
|
54
58
|
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { parseMarkdown } from './parseMarkdown';
|
|
4
|
+
import { groupSections } from './groupSections';
|
|
5
|
+
|
|
6
|
+
const sections = (md: string) => groupSections(parseMarkdown(md).ast.children);
|
|
7
|
+
|
|
8
|
+
describe('groupSections', () => {
|
|
9
|
+
describe('empty input', () => {
|
|
10
|
+
it('returns an empty array', () => {
|
|
11
|
+
expect(sections('')).toHaveLength(0);
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe('content with no headings', () => {
|
|
16
|
+
it('returns a single untitled section containing all nodes', () => {
|
|
17
|
+
const result = sections('some text\n\nmore text');
|
|
18
|
+
expect(result).toHaveLength(1);
|
|
19
|
+
expect(result[0].title).toBe('');
|
|
20
|
+
expect(result[0].nodes).toHaveLength(2);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('top-level sections', () => {
|
|
25
|
+
it('creates a section for H1', () => {
|
|
26
|
+
const result = sections('# Title');
|
|
27
|
+
expect(result).toHaveLength(1);
|
|
28
|
+
expect(result[0].title).toBe('Title');
|
|
29
|
+
expect(result[0].headingLevel).toBe(1);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('creates a section for H2', () => {
|
|
33
|
+
const result = sections('## Section');
|
|
34
|
+
expect(result).toHaveLength(1);
|
|
35
|
+
expect(result[0].title).toBe('Section');
|
|
36
|
+
expect(result[0].headingLevel).toBe(2);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('creates multiple sections for sequential H2s', () => {
|
|
40
|
+
const result = sections('## One\n\n## Two\n\n## Three');
|
|
41
|
+
expect(result).toHaveLength(3);
|
|
42
|
+
expect(result.map(s => s.title)).toEqual(['One', 'Two', 'Three']);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('groups content nodes under their section', () => {
|
|
46
|
+
const result = sections('## Section\n\nsome text\n\nmore text');
|
|
47
|
+
expect(result).toHaveLength(1);
|
|
48
|
+
expect(result[0].nodes).toHaveLength(2);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('intro content before headings', () => {
|
|
53
|
+
it('returns an untitled intro section followed by headed sections', () => {
|
|
54
|
+
const result = sections('intro text\n\n## Section');
|
|
55
|
+
expect(result).toHaveLength(2);
|
|
56
|
+
expect(result[0].title).toBe('');
|
|
57
|
+
expect(result[0].nodes).toHaveLength(1);
|
|
58
|
+
expect(result[1].title).toBe('Section');
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('nested sections (H3)', () => {
|
|
63
|
+
it('nests H3 under a preceding H2', () => {
|
|
64
|
+
const result = sections('## Parent\n\n### Child');
|
|
65
|
+
expect(result).toHaveLength(1);
|
|
66
|
+
expect(result[0].subsections).toHaveLength(1);
|
|
67
|
+
expect(result[0].subsections[0].title).toBe('Child');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('promotes H3 to top-level when no parent section exists', () => {
|
|
71
|
+
const result = sections('### Orphan');
|
|
72
|
+
expect(result).toHaveLength(1);
|
|
73
|
+
expect(result[0].title).toBe('Orphan');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('creates multiple subsections under one parent', () => {
|
|
77
|
+
const result = sections('## Parent\n\n### A\n\n### B');
|
|
78
|
+
expect(result).toHaveLength(1);
|
|
79
|
+
expect(result[0].subsections.map(s => s.title)).toEqual(['A', 'B']);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('deeper headings (H4+)', () => {
|
|
84
|
+
it('nests H4 as a subsection of H2', () => {
|
|
85
|
+
const result = sections('## Section\n\n#### Note');
|
|
86
|
+
expect(result[0].subsections).toHaveLength(1);
|
|
87
|
+
expect(result[0].subsections[0].headingLevel).toBe(4);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('nests H6 inside H5 inside H2', () => {
|
|
91
|
+
const result = sections('## Section\n\n##### Five\n\n###### Six');
|
|
92
|
+
const h5 = result[0].subsections[0];
|
|
93
|
+
expect(h5.headingLevel).toBe(5);
|
|
94
|
+
expect(h5.subsections[0].headingLevel).toBe(6);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
});
|
package/utils/groupSections.ts
CHANGED
|
@@ -4,111 +4,67 @@ import { extractText } from './extractText';
|
|
|
4
4
|
|
|
5
5
|
export type Section = {
|
|
6
6
|
title: string;
|
|
7
|
+
headingLevel: number;
|
|
7
8
|
nodes: RootContent[];
|
|
8
9
|
subsections: Section[];
|
|
10
|
+
previousSection?: Section;
|
|
9
11
|
};
|
|
10
12
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
subsections: [],
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
// Closes over `sections` (const array — mutation only, CFA-safe).
|
|
25
|
-
// Callers assign the return value to `currentSection` directly in the
|
|
26
|
-
// outer scope so TypeScript's CFA can track the narrowing correctly.
|
|
27
|
-
const addSection = (title: string, sections: Section[]): Section => {
|
|
28
|
-
const section = makeSection(title);
|
|
29
|
-
sections.push(section);
|
|
30
|
-
return section;
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
export const groupSections = (nodes: RootContent[]): GroupedSections => {
|
|
34
|
-
let pageTitle: string | null = null;
|
|
35
|
-
const beforeFirstHeading: RootContent[] = [];
|
|
36
|
-
const textBeforeFirstSection: RootContent[] = [];
|
|
37
|
-
const sections: Section[] = [];
|
|
38
|
-
let currentSection: Section | null = null;
|
|
39
|
-
let currentSubsection: Section | null = null;
|
|
40
|
-
let seenFirstHeading = false;
|
|
41
|
-
let afterH1 = false;
|
|
42
|
-
let seenH4InCurrentSection = false;
|
|
43
|
-
|
|
44
|
-
for (const node of nodes) {
|
|
45
|
-
if (node.type === 'heading') {
|
|
46
|
-
seenFirstHeading = true;
|
|
47
|
-
|
|
48
|
-
switch (node.depth) {
|
|
49
|
-
case 1: {
|
|
50
|
-
if (sections.length === 0) {
|
|
51
|
-
pageTitle = extractText(node.children);
|
|
52
|
-
afterH1 = true;
|
|
53
|
-
} else {
|
|
54
|
-
currentSubsection = null;
|
|
55
|
-
seenH4InCurrentSection = false;
|
|
56
|
-
currentSection = addSection(extractText(node.children), sections);
|
|
57
|
-
}
|
|
58
|
-
break;
|
|
59
|
-
}
|
|
60
|
-
case 2: {
|
|
61
|
-
currentSubsection = null;
|
|
62
|
-
seenH4InCurrentSection = false;
|
|
63
|
-
currentSection = addSection(extractText(node.children), sections);
|
|
64
|
-
afterH1 = false;
|
|
65
|
-
break;
|
|
66
|
-
}
|
|
67
|
-
case 3: {
|
|
68
|
-
if (seenH4InCurrentSection) {
|
|
69
|
-
currentSubsection = null;
|
|
70
|
-
seenH4InCurrentSection = false;
|
|
71
|
-
currentSection = addSection(extractText(node.children), sections);
|
|
72
|
-
} else {
|
|
73
|
-
currentSubsection = makeSection(extractText(node.children));
|
|
74
|
-
currentSection?.subsections.push(currentSubsection);
|
|
75
|
-
}
|
|
76
|
-
break;
|
|
77
|
-
}
|
|
78
|
-
case 4: {
|
|
79
|
-
seenH4InCurrentSection = true;
|
|
80
|
-
(currentSubsection ?? currentSection)?.nodes.push(node);
|
|
81
|
-
break;
|
|
82
|
-
}
|
|
83
|
-
case 5:
|
|
84
|
-
case 6: {
|
|
85
|
-
(currentSubsection ?? currentSection)?.nodes.push(node);
|
|
86
|
-
break;
|
|
87
|
-
}
|
|
88
|
-
// No default
|
|
89
|
-
}
|
|
90
|
-
continue;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
if (!seenFirstHeading) {
|
|
94
|
-
beforeFirstHeading.push(node);
|
|
95
|
-
continue;
|
|
96
|
-
}
|
|
13
|
+
const consumeNode = (
|
|
14
|
+
nodes: RootContent[],
|
|
15
|
+
index: number,
|
|
16
|
+
sections: Section[],
|
|
17
|
+
section: Section
|
|
18
|
+
): Section[] => {
|
|
19
|
+
const node = nodes.at(index);
|
|
20
|
+
if (!node) {
|
|
21
|
+
return sections;
|
|
22
|
+
}
|
|
97
23
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
24
|
+
const isHeading = node.type === 'heading';
|
|
25
|
+
if (!isHeading) {
|
|
26
|
+
section.nodes.push(node);
|
|
27
|
+
return consumeNode(nodes, index + 1, sections, section);
|
|
28
|
+
}
|
|
102
29
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
30
|
+
const headingLevel = node.depth;
|
|
31
|
+
const isIncrementingHeading = headingLevel > section.headingLevel;
|
|
32
|
+
if (isIncrementingHeading) {
|
|
33
|
+
const subsection: Section = {
|
|
34
|
+
title: extractText(node.children),
|
|
35
|
+
headingLevel,
|
|
36
|
+
nodes: [],
|
|
37
|
+
subsections: [],
|
|
38
|
+
previousSection: section,
|
|
39
|
+
};
|
|
40
|
+
section.subsections.push(subsection);
|
|
41
|
+
return consumeNode(nodes, index + 1, sections, subsection);
|
|
42
|
+
}
|
|
107
43
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
44
|
+
if (!section.previousSection) {
|
|
45
|
+
const subSection: Section = {
|
|
46
|
+
title: extractText(node.children),
|
|
47
|
+
headingLevel,
|
|
48
|
+
nodes: [],
|
|
49
|
+
subsections: [],
|
|
50
|
+
previousSection: undefined,
|
|
51
|
+
};
|
|
52
|
+
sections.push(subSection);
|
|
53
|
+
return consumeNode(nodes, index + 1, sections, subSection);
|
|
111
54
|
}
|
|
112
55
|
|
|
113
|
-
return
|
|
56
|
+
return consumeNode(nodes, index, sections, section.previousSection);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export const groupSections = (nodes: RootContent[]): Section[] => {
|
|
60
|
+
const initialSection: Section = {
|
|
61
|
+
title: '',
|
|
62
|
+
headingLevel: Infinity,
|
|
63
|
+
nodes: [],
|
|
64
|
+
subsections: [],
|
|
65
|
+
previousSection: undefined,
|
|
66
|
+
};
|
|
67
|
+
const sections: Section[] = [initialSection];
|
|
68
|
+
consumeNode(nodes, 0, sections, initialSection);
|
|
69
|
+
return sections.filter(s => s.title !== '' || s.nodes.length > 0);
|
|
114
70
|
};
|
package/utils/parseMarkdown.ts
CHANGED
|
@@ -1,9 +1,35 @@
|
|
|
1
|
-
import type { Root } from 'mdast';
|
|
1
|
+
import type { Root, Yaml } from 'mdast';
|
|
2
|
+
import remarkFrontmatter from 'remark-frontmatter';
|
|
2
3
|
import remarkGfm from 'remark-gfm';
|
|
3
4
|
import remarkParse from 'remark-parse';
|
|
4
5
|
import { unified } from 'unified';
|
|
6
|
+
import { parse as parseYaml } from 'yaml';
|
|
5
7
|
|
|
6
|
-
export
|
|
7
|
-
|
|
8
|
-
|
|
8
|
+
export type Frontmatter = {
|
|
9
|
+
title?: string;
|
|
10
|
+
description?: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type ParseResult = {
|
|
14
|
+
ast: Root;
|
|
15
|
+
frontmatter: Frontmatter;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const parseMarkdown = (content: string): ParseResult => {
|
|
19
|
+
const processor = unified().use(remarkParse).use(remarkGfm).use(remarkFrontmatter, ['yaml']);
|
|
20
|
+
const ast = processor.parse(content) as Root;
|
|
21
|
+
|
|
22
|
+
let frontmatter: Frontmatter = {};
|
|
23
|
+
|
|
24
|
+
if (ast.children[0]?.type === 'yaml') {
|
|
25
|
+
const raw = (ast.children[0] as Yaml).value;
|
|
26
|
+
const parsed = parseYaml(raw) as Record<string, unknown>;
|
|
27
|
+
frontmatter = {
|
|
28
|
+
title: typeof parsed.title === 'string' ? parsed.title : undefined,
|
|
29
|
+
description: typeof parsed.description === 'string' ? parsed.description : undefined,
|
|
30
|
+
};
|
|
31
|
+
ast.children.shift();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return { ast, frontmatter };
|
|
9
35
|
};
|
package/utils/renderMarkdown.tsx
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
BlogSection,
|
|
5
|
+
Callout,
|
|
6
|
+
CodeBlock,
|
|
7
|
+
Mermaid,
|
|
8
|
+
Table,
|
|
9
|
+
} from '@san-siva/blogkit';
|
|
4
10
|
import type { Root, RootContent } from 'mdast';
|
|
5
11
|
|
|
6
12
|
import type { Section } from './groupSections';
|
|
@@ -9,21 +15,34 @@ import { renderPhrasingContent } from './renderPhrasingContent';
|
|
|
9
15
|
|
|
10
16
|
import styles from '@san-siva/stylekit/styles/index.module.scss';
|
|
11
17
|
|
|
12
|
-
function renderNode(
|
|
13
|
-
node
|
|
14
|
-
key
|
|
15
|
-
nextNode
|
|
16
|
-
inList = false
|
|
17
|
-
|
|
18
|
+
function renderNode({
|
|
19
|
+
node,
|
|
20
|
+
key,
|
|
21
|
+
nextNode,
|
|
22
|
+
inList = false,
|
|
23
|
+
inCallout = false,
|
|
24
|
+
}: {
|
|
25
|
+
node: RootContent;
|
|
26
|
+
key: number;
|
|
27
|
+
nextNode?: RootContent;
|
|
28
|
+
inList?: boolean;
|
|
29
|
+
inCallout?: boolean;
|
|
30
|
+
}): React.ReactNode {
|
|
18
31
|
switch (node.type) {
|
|
19
32
|
case 'paragraph': {
|
|
20
33
|
if (inList) {
|
|
21
34
|
return <p key={key}>{renderPhrasingContent(node.children)}</p>;
|
|
22
35
|
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
36
|
+
|
|
37
|
+
const isFollowedByParagraph = nextNode?.type === 'paragraph';
|
|
38
|
+
const isLastInCallout = !nextNode && inCallout;
|
|
39
|
+
|
|
40
|
+
const marginClass = isFollowedByParagraph
|
|
41
|
+
? styles['margin-bottom--1']
|
|
42
|
+
: isLastInCallout
|
|
43
|
+
? undefined
|
|
26
44
|
: styles['margin-bottom--2'];
|
|
45
|
+
|
|
27
46
|
return (
|
|
28
47
|
<p key={key} className={marginClass}>
|
|
29
48
|
{renderPhrasingContent(node.children)}
|
|
@@ -84,10 +103,52 @@ function renderNode(
|
|
|
84
103
|
}
|
|
85
104
|
case 'blockquote': {
|
|
86
105
|
const children = node.children as RootContent[];
|
|
106
|
+
let calloutType: 'info' | 'warning' | 'error' = 'info';
|
|
107
|
+
let strippedChildren = children;
|
|
108
|
+
|
|
109
|
+
const firstChild = children[0];
|
|
110
|
+
if (firstChild?.type === 'paragraph') {
|
|
111
|
+
const firstInline = firstChild.children[0];
|
|
112
|
+
if (firstInline?.type === 'text') {
|
|
113
|
+
if (firstInline.value.startsWith('!')) {
|
|
114
|
+
calloutType = 'error';
|
|
115
|
+
const trimmed = firstInline.value.slice(1).trimStart();
|
|
116
|
+
strippedChildren = [
|
|
117
|
+
{
|
|
118
|
+
...firstChild,
|
|
119
|
+
children: [
|
|
120
|
+
{ ...firstInline, value: trimmed },
|
|
121
|
+
...firstChild.children.slice(1),
|
|
122
|
+
],
|
|
123
|
+
},
|
|
124
|
+
...children.slice(1),
|
|
125
|
+
];
|
|
126
|
+
} else if (firstInline.value.startsWith('~')) {
|
|
127
|
+
calloutType = 'warning';
|
|
128
|
+
const trimmed = firstInline.value.slice(1).trimStart();
|
|
129
|
+
strippedChildren = [
|
|
130
|
+
{
|
|
131
|
+
...firstChild,
|
|
132
|
+
children: [
|
|
133
|
+
{ ...firstInline, value: trimmed },
|
|
134
|
+
...firstChild.children.slice(1),
|
|
135
|
+
],
|
|
136
|
+
},
|
|
137
|
+
...children.slice(1),
|
|
138
|
+
];
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
87
143
|
return (
|
|
88
|
-
<Callout key={key} type=
|
|
89
|
-
{
|
|
90
|
-
renderNode(
|
|
144
|
+
<Callout key={key} type={calloutType} hasMarginUp hasMarginDown>
|
|
145
|
+
{strippedChildren.map((child, index) =>
|
|
146
|
+
renderNode({
|
|
147
|
+
node: child,
|
|
148
|
+
key: index,
|
|
149
|
+
nextNode: strippedChildren[index + 1],
|
|
150
|
+
inCallout: true,
|
|
151
|
+
})
|
|
91
152
|
)}
|
|
92
153
|
</Callout>
|
|
93
154
|
);
|
|
@@ -95,11 +156,15 @@ function renderNode(
|
|
|
95
156
|
case 'list': {
|
|
96
157
|
const Tag = node.ordered ? 'ol' : 'ul';
|
|
97
158
|
return (
|
|
98
|
-
<Tag key={key}>
|
|
159
|
+
<Tag key={key} className={styles['margin-bottom--2']}>
|
|
99
160
|
{node.children.map((item, index) => (
|
|
100
161
|
<li key={index}>
|
|
101
162
|
{item.children.map((child, index) =>
|
|
102
|
-
renderNode(
|
|
163
|
+
renderNode({
|
|
164
|
+
node: child as RootContent,
|
|
165
|
+
key: index,
|
|
166
|
+
inList: true,
|
|
167
|
+
})
|
|
103
168
|
)}
|
|
104
169
|
</li>
|
|
105
170
|
))}
|
|
@@ -113,12 +178,14 @@ function renderNode(
|
|
|
113
178
|
}
|
|
114
179
|
|
|
115
180
|
function renderNodes(nodes: RootContent[]): React.ReactNode[] {
|
|
116
|
-
return nodes.map((node, index) =>
|
|
181
|
+
return nodes.map((node, index) =>
|
|
182
|
+
renderNode({ node, key: index, nextNode: nodes[index + 1] })
|
|
183
|
+
);
|
|
117
184
|
}
|
|
118
185
|
|
|
119
|
-
function renderSection(section: Section, key
|
|
186
|
+
function renderSection(section: Section, key = -1): React.ReactNode {
|
|
120
187
|
return (
|
|
121
|
-
<BlogSection key={key} title={section
|
|
188
|
+
<BlogSection key={key} title={section?.title ?? ''}>
|
|
122
189
|
{renderNodes(section.nodes)}
|
|
123
190
|
{section.subsections.map((subsection, index) =>
|
|
124
191
|
renderSection(subsection, index)
|
|
@@ -128,21 +195,13 @@ function renderSection(section: Section, key: number): React.ReactNode {
|
|
|
128
195
|
}
|
|
129
196
|
|
|
130
197
|
export type RenderedMarkdown = {
|
|
131
|
-
pageTitle: string | null;
|
|
132
|
-
beforeFirstHeading: React.ReactNode[];
|
|
133
|
-
textBeforeFirstSection: React.ReactNode[];
|
|
134
198
|
sections: React.ReactNode[];
|
|
135
199
|
};
|
|
136
200
|
|
|
137
201
|
export const renderMarkdownAst = (ast: Root): RenderedMarkdown => {
|
|
138
|
-
const
|
|
139
|
-
groupSections(ast.children);
|
|
140
|
-
|
|
202
|
+
const grouped = groupSections(ast.children);
|
|
141
203
|
return {
|
|
142
|
-
|
|
143
|
-
beforeFirstHeading: renderNodes(beforeFirstHeading),
|
|
144
|
-
textBeforeFirstSection: renderNodes(textBeforeFirstSection),
|
|
145
|
-
sections: sections.map((section, index) => renderSection(section, index)),
|
|
204
|
+
sections: grouped.map((section, index) => renderSection(section, index)),
|
|
146
205
|
};
|
|
147
206
|
};
|
|
148
207
|
|
|
@@ -150,14 +209,4 @@ export const MarkdownSections = ({
|
|
|
150
209
|
rendered,
|
|
151
210
|
}: {
|
|
152
211
|
rendered: RenderedMarkdown;
|
|
153
|
-
}): React.ReactNode =>
|
|
154
|
-
<>
|
|
155
|
-
{rendered.beforeFirstHeading.length > 0 && (
|
|
156
|
-
<BlogSection>{rendered.beforeFirstHeading}</BlogSection>
|
|
157
|
-
)}
|
|
158
|
-
{rendered.textBeforeFirstSection.length > 0 && (
|
|
159
|
-
<BlogSection>{rendered.textBeforeFirstSection}</BlogSection>
|
|
160
|
-
)}
|
|
161
|
-
{rendered.sections}
|
|
162
|
-
</>
|
|
163
|
-
);
|
|
212
|
+
}): React.ReactNode => <>{rendered.sections}</>;
|
|
@@ -25,18 +25,20 @@
|
|
|
25
25
|
},
|
|
26
26
|
"..": {
|
|
27
27
|
"name": "@san-siva/blogkit-md",
|
|
28
|
-
"version": "0.1.
|
|
28
|
+
"version": "0.1.1",
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@san-siva/blogkit": "^1.1.20",
|
|
31
31
|
"@san-siva/stylekit": "^1.0.8",
|
|
32
32
|
"next": "^16.0.10",
|
|
33
33
|
"react": "^19.0.0",
|
|
34
34
|
"react-dom": "^19.0.0",
|
|
35
|
+
"remark-frontmatter": "^5.0.0",
|
|
35
36
|
"remark-gfm": "^4.0.1",
|
|
36
37
|
"remark-parse": "^11.0.0",
|
|
37
38
|
"sass": "^1.95.1",
|
|
38
39
|
"schema-dts": "^1.1.2",
|
|
39
|
-
"unified": "^11.0.5"
|
|
40
|
+
"unified": "^11.0.5",
|
|
41
|
+
"yaml": "^2.8.2"
|
|
40
42
|
},
|
|
41
43
|
"devDependencies": {
|
|
42
44
|
"@eslint/js": "^9.0.0",
|
|
@@ -62,7 +64,8 @@
|
|
|
62
64
|
"prettier": "^3.0.0",
|
|
63
65
|
"tsx": "^4.0.0",
|
|
64
66
|
"typescript": "^5.0.0",
|
|
65
|
-
"typescript-eslint": "^8.0.0"
|
|
67
|
+
"typescript-eslint": "^8.0.0",
|
|
68
|
+
"vitest": "^4.1.0"
|
|
66
69
|
}
|
|
67
70
|
},
|
|
68
71
|
"node_modules/@babel/runtime": {
|