@san-siva/blogkit-md 0.1.1 → 0.2.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.
- package/README.md +88 -54
- package/components/BlogPost.tsx +5 -4
- package/hooks/readMarkdownFile.ts +8 -10
- package/index.ts +2 -0
- package/package.json +8 -8
- package/utils/groupSections.test.ts +97 -0
- package/utils/groupSections.ts +54 -98
- package/utils/parseMarkdown.ts +30 -4
- package/utils/renderMarkdown.tsx +41 -35
- package/website/package-lock.json +6 -3
- package/app/layout.tsx +0 -44
- package/app/page.tsx +0 -27
- package/next.config.ts +0 -5
- package/scripts/dev.ts +0 -50
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,27 +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 | Syntax
|
|
93
|
-
| -------------------- |
|
|
94
|
-
|
|
|
95
|
-
|
|
|
96
|
-
|
|
|
97
|
-
| Bold
|
|
98
|
-
|
|
|
99
|
-
|
|
|
100
|
-
|
|
|
101
|
-
|
|
|
102
|
-
|
|
|
103
|
-
|
|
|
104
|
-
|
|
|
105
|
-
|
|
|
106
|
-
|
|
|
107
|
-
|
|
|
108
|
-
|
|
|
109
|
-
|
|
|
110
|
-
|
|
|
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 | `\| col \| col \|` — headers and rows only |
|
|
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 |
|
|
111
133
|
|
|
112
134
|
## Philosophy
|
|
113
135
|
|
|
@@ -143,36 +165,30 @@ flowchart LR
|
|
|
143
165
|
|
|
144
166
|
### Headings as Layout Triggers
|
|
145
167
|
|
|
146
|
-
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**.
|
|
147
169
|
|
|
148
|
-
| Markdown
|
|
149
|
-
|
|
|
150
|
-
| `# H1`
|
|
151
|
-
|
|
|
152
|
-
|
|
|
153
|
-
| `#### 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. |
|
|
154
|
-
| `##### 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. |
|
|
155
175
|
|
|
156
|
-
> Standard content—
|
|
176
|
+
> Standard content — paragraphs, lists, code blocks — flows into the most recently opened section or subsection.
|
|
157
177
|
|
|
158
|
-
###
|
|
178
|
+
### The Nesting Logic
|
|
159
179
|
|
|
160
|
-
|
|
180
|
+
The layout is determined entirely by heading level (depth):
|
|
161
181
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
| `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. |
|
|
166
|
-
| 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.
|
|
167
185
|
|
|
168
186
|
### Visualizing the Structure
|
|
169
187
|
|
|
170
|
-
|
|
188
|
+
Here is how a standard markdown document maps to blog layout:
|
|
171
189
|
|
|
172
190
|
```markdown
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
This text becomes the Preamble (an untitled, top-level section).
|
|
191
|
+
Intro content
|
|
176
192
|
|
|
177
193
|
## The Setup
|
|
178
194
|
|
|
@@ -184,9 +200,17 @@ Nested content belongs here.
|
|
|
184
200
|
|
|
185
201
|
## The Execution
|
|
186
202
|
|
|
187
|
-
|
|
203
|
+
Some more content.
|
|
188
204
|
|
|
189
205
|
### The Results
|
|
206
|
+
|
|
207
|
+
Result content.
|
|
208
|
+
|
|
209
|
+
# A Note
|
|
210
|
+
|
|
211
|
+
### A Subsection
|
|
212
|
+
|
|
213
|
+
## Also Nested
|
|
190
214
|
```
|
|
191
215
|
|
|
192
216
|
Here is how the parser breaks the above document down into isolated React components:
|
|
@@ -194,7 +218,7 @@ Here is how the parser breaks the above document down into isolated React compon
|
|
|
194
218
|
##### Intro section
|
|
195
219
|
|
|
196
220
|
```markdown
|
|
197
|
-
|
|
221
|
+
Intro content
|
|
198
222
|
```
|
|
199
223
|
|
|
200
224
|
##### Section 1
|
|
@@ -204,7 +228,7 @@ This text becomes an introductory, untitled section.
|
|
|
204
228
|
|
|
205
229
|
Some content goes here.
|
|
206
230
|
|
|
207
|
-
|
|
231
|
+
### Prerequisites
|
|
208
232
|
|
|
209
233
|
Nested content belongs here.
|
|
210
234
|
```
|
|
@@ -214,24 +238,34 @@ Nested content belongs here.
|
|
|
214
238
|
```markdown
|
|
215
239
|
## The Execution
|
|
216
240
|
|
|
217
|
-
|
|
241
|
+
Some more content.
|
|
242
|
+
|
|
243
|
+
### The Results
|
|
244
|
+
|
|
245
|
+
Result content.
|
|
218
246
|
```
|
|
219
247
|
|
|
220
248
|
##### Section 3
|
|
221
249
|
|
|
222
250
|
```markdown
|
|
223
|
-
|
|
251
|
+
# A Note
|
|
252
|
+
|
|
253
|
+
### A Subsection
|
|
254
|
+
|
|
255
|
+
## Also Nested
|
|
224
256
|
```
|
|
225
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
|
+
|
|
226
260
|
### Callouts
|
|
227
261
|
|
|
228
|
-
Blockquotes are rendered as styled callout banners. The callout type is controlled by a prefix on the first word of the quote
|
|
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
|
+
> This is an info callout. Use `> text` for general information.
|
|
265
|
+
|
|
266
|
+
> ~This is a warning callout. Use `> ~text` to flag caution.
|
|
229
267
|
|
|
230
|
-
|
|
231
|
-
| :-------- | :----------- | :--------------------------- |
|
|
232
|
-
| `> text` | Info | `> This is an info callout.` |
|
|
233
|
-
| `> ~text` | Warning | `> ~This is a warning.` |
|
|
234
|
-
| `> !text` | Error | `> !This is an error.` |
|
|
268
|
+
> !This is an error callout. Use `> !text` for errors or destructive actions.
|
|
235
269
|
|
|
236
270
|
The prefix character is stripped from the rendered output — only the callout style changes.
|
|
237
271
|
|
|
@@ -239,12 +273,12 @@ The prefix character is stripped from the rendered output — only the callout s
|
|
|
239
273
|
|
|
240
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/).
|
|
241
275
|
|
|
242
|
-
|
|
276
|
+
### License
|
|
243
277
|
|
|
244
278
|
`blogkit-md` is open source software licensed under the [MIT license](https://github.com/san-siva/blogkit-md/blob/main/LICENSE).
|
|
245
279
|
Contributions are welcome!
|
|
246
280
|
|
|
247
|
-
|
|
281
|
+
### About
|
|
248
282
|
|
|
249
283
|
- **Author:** [Santhosh Siva](https://www.santhoshsiva.dev)
|
|
250
284
|
- **License:** [MIT](https://github.com/san-siva/blogkit-md/blob/main/LICENSE)
|
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
|
);
|
|
@@ -1,27 +1,25 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
|
|
4
|
-
import '@/utils/
|
|
4
|
+
import type { Frontmatter } from '@/utils/parseMarkdown';
|
|
5
5
|
import { parseMarkdown } from '@/utils/parseMarkdown';
|
|
6
6
|
import type { RenderedMarkdown } from '@/utils/renderMarkdown';
|
|
7
7
|
import { renderMarkdownAst } from '@/utils/renderMarkdown';
|
|
8
8
|
|
|
9
9
|
type MarkdownFileResult =
|
|
10
|
-
| { success: true; rendered: RenderedMarkdown }
|
|
10
|
+
| { success: true; rendered: RenderedMarkdown; frontmatter: Frontmatter }
|
|
11
11
|
| { success: false; error: string };
|
|
12
12
|
|
|
13
13
|
export const readMarkdownFile = async (
|
|
14
14
|
filePath: string | undefined
|
|
15
15
|
): Promise<MarkdownFileResult> => {
|
|
16
16
|
if (!filePath) {
|
|
17
|
-
return {
|
|
18
|
-
success: false,
|
|
19
|
-
error:
|
|
20
|
-
'MARKDOWN_FILE env variable is required. Usage: MARKDOWN_FILE=data/test.md npm run dev',
|
|
21
|
-
};
|
|
17
|
+
return { success: false, error: 'No file path provided.' };
|
|
22
18
|
}
|
|
23
19
|
|
|
24
|
-
const absolutePath = path.
|
|
20
|
+
const absolutePath = path.isAbsolute(filePath)
|
|
21
|
+
? filePath
|
|
22
|
+
: path.join(process.cwd(), filePath);
|
|
25
23
|
|
|
26
24
|
let content: string;
|
|
27
25
|
try {
|
|
@@ -37,8 +35,8 @@ export const readMarkdownFile = async (
|
|
|
37
35
|
return { success: false, error: `File "${filePath}" is empty.` };
|
|
38
36
|
}
|
|
39
37
|
|
|
40
|
-
const ast = parseMarkdown(content);
|
|
38
|
+
const { ast, frontmatter } = parseMarkdown(content);
|
|
41
39
|
const rendered = renderMarkdownAst(ast);
|
|
42
40
|
|
|
43
|
-
return { success: true, rendered };
|
|
41
|
+
return { success: true, rendered, frontmatter };
|
|
44
42
|
};
|
package/index.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@san-siva/blogkit-md",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Converts markdown files into JSX blog posts for Blogkit",
|
|
5
5
|
"main": "index.ts",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": "./index.ts"
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
|
-
"
|
|
11
|
-
"build": "MARKDOWN_FILE=data/test.md next build",
|
|
12
|
-
"start": "next start",
|
|
10
|
+
"test": "vitest run",
|
|
13
11
|
"lint": "eslint . --config eslint.config.ts",
|
|
14
12
|
"fix": "eslint . --config eslint.config.ts --fix"
|
|
15
13
|
},
|
|
@@ -19,11 +17,13 @@
|
|
|
19
17
|
"next": "^16.0.10",
|
|
20
18
|
"react": "^19.0.0",
|
|
21
19
|
"react-dom": "^19.0.0",
|
|
20
|
+
"remark-frontmatter": "^5.0.0",
|
|
22
21
|
"remark-gfm": "^4.0.1",
|
|
23
|
-
"schema-dts": "^1.1.2",
|
|
24
22
|
"remark-parse": "^11.0.0",
|
|
25
23
|
"sass": "^1.95.1",
|
|
26
|
-
"
|
|
24
|
+
"schema-dts": "^1.1.2",
|
|
25
|
+
"unified": "^11.0.5",
|
|
26
|
+
"yaml": "^2.8.2"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
29
|
"@eslint/js": "^9.0.0",
|
|
@@ -47,8 +47,8 @@
|
|
|
47
47
|
"eslint-plugin-unicorn": "^62.0.0",
|
|
48
48
|
"jiti": "^2.0.0",
|
|
49
49
|
"prettier": "^3.0.0",
|
|
50
|
-
"tsx": "^4.0.0",
|
|
51
50
|
"typescript": "^5.0.0",
|
|
52
|
-
"typescript-eslint": "^8.0.0"
|
|
51
|
+
"typescript-eslint": "^8.0.0",
|
|
52
|
+
"vitest": "^4.1.0"
|
|
53
53
|
}
|
|
54
54
|
}
|
|
@@ -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
|
@@ -15,21 +15,34 @@ import { renderPhrasingContent } from './renderPhrasingContent';
|
|
|
15
15
|
|
|
16
16
|
import styles from '@san-siva/stylekit/styles/index.module.scss';
|
|
17
17
|
|
|
18
|
-
function renderNode(
|
|
19
|
-
node
|
|
20
|
-
key
|
|
21
|
-
nextNode
|
|
22
|
-
inList = false
|
|
23
|
-
|
|
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 {
|
|
24
31
|
switch (node.type) {
|
|
25
32
|
case 'paragraph': {
|
|
26
33
|
if (inList) {
|
|
27
34
|
return <p key={key}>{renderPhrasingContent(node.children)}</p>;
|
|
28
35
|
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
32
44
|
: styles['margin-bottom--2'];
|
|
45
|
+
|
|
33
46
|
return (
|
|
34
47
|
<p key={key} className={marginClass}>
|
|
35
48
|
{renderPhrasingContent(node.children)}
|
|
@@ -130,7 +143,12 @@ function renderNode(
|
|
|
130
143
|
return (
|
|
131
144
|
<Callout key={key} type={calloutType} hasMarginUp hasMarginDown>
|
|
132
145
|
{strippedChildren.map((child, index) =>
|
|
133
|
-
renderNode(
|
|
146
|
+
renderNode({
|
|
147
|
+
node: child,
|
|
148
|
+
key: index,
|
|
149
|
+
nextNode: strippedChildren[index + 1],
|
|
150
|
+
inCallout: true,
|
|
151
|
+
})
|
|
134
152
|
)}
|
|
135
153
|
</Callout>
|
|
136
154
|
);
|
|
@@ -142,7 +160,11 @@ function renderNode(
|
|
|
142
160
|
{node.children.map((item, index) => (
|
|
143
161
|
<li key={index}>
|
|
144
162
|
{item.children.map((child, index) =>
|
|
145
|
-
renderNode(
|
|
163
|
+
renderNode({
|
|
164
|
+
node: child as RootContent,
|
|
165
|
+
key: index,
|
|
166
|
+
inList: true,
|
|
167
|
+
})
|
|
146
168
|
)}
|
|
147
169
|
</li>
|
|
148
170
|
))}
|
|
@@ -156,12 +178,14 @@ function renderNode(
|
|
|
156
178
|
}
|
|
157
179
|
|
|
158
180
|
function renderNodes(nodes: RootContent[]): React.ReactNode[] {
|
|
159
|
-
return nodes.map((node, index) =>
|
|
181
|
+
return nodes.map((node, index) =>
|
|
182
|
+
renderNode({ node, key: index, nextNode: nodes[index + 1] })
|
|
183
|
+
);
|
|
160
184
|
}
|
|
161
185
|
|
|
162
|
-
function renderSection(section: Section, key
|
|
186
|
+
function renderSection(section: Section, key = -1): React.ReactNode {
|
|
163
187
|
return (
|
|
164
|
-
<BlogSection key={key} title={section
|
|
188
|
+
<BlogSection key={key} title={section?.title ?? ''}>
|
|
165
189
|
{renderNodes(section.nodes)}
|
|
166
190
|
{section.subsections.map((subsection, index) =>
|
|
167
191
|
renderSection(subsection, index)
|
|
@@ -171,21 +195,13 @@ function renderSection(section: Section, key: number): React.ReactNode {
|
|
|
171
195
|
}
|
|
172
196
|
|
|
173
197
|
export type RenderedMarkdown = {
|
|
174
|
-
pageTitle: string | null;
|
|
175
|
-
beforeFirstHeading: React.ReactNode[];
|
|
176
|
-
textBeforeFirstSection: React.ReactNode[];
|
|
177
198
|
sections: React.ReactNode[];
|
|
178
199
|
};
|
|
179
200
|
|
|
180
201
|
export const renderMarkdownAst = (ast: Root): RenderedMarkdown => {
|
|
181
|
-
const
|
|
182
|
-
groupSections(ast.children);
|
|
183
|
-
|
|
202
|
+
const grouped = groupSections(ast.children);
|
|
184
203
|
return {
|
|
185
|
-
|
|
186
|
-
beforeFirstHeading: renderNodes(beforeFirstHeading),
|
|
187
|
-
textBeforeFirstSection: renderNodes(textBeforeFirstSection),
|
|
188
|
-
sections: sections.map((section, index) => renderSection(section, index)),
|
|
204
|
+
sections: grouped.map((section, index) => renderSection(section, index)),
|
|
189
205
|
};
|
|
190
206
|
};
|
|
191
207
|
|
|
@@ -193,14 +209,4 @@ export const MarkdownSections = ({
|
|
|
193
209
|
rendered,
|
|
194
210
|
}: {
|
|
195
211
|
rendered: RenderedMarkdown;
|
|
196
|
-
}): React.ReactNode =>
|
|
197
|
-
<>
|
|
198
|
-
{rendered.beforeFirstHeading.length > 0 && (
|
|
199
|
-
<BlogSection>{rendered.beforeFirstHeading}</BlogSection>
|
|
200
|
-
)}
|
|
201
|
-
{rendered.textBeforeFirstSection.length > 0 && (
|
|
202
|
-
<BlogSection>{rendered.textBeforeFirstSection}</BlogSection>
|
|
203
|
-
)}
|
|
204
|
-
{rendered.sections}
|
|
205
|
-
</>
|
|
206
|
-
);
|
|
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.2",
|
|
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": {
|
package/app/layout.tsx
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import type { Metadata } from 'next';
|
|
2
|
-
import { JetBrains_Mono, Montserrat, Rubik } from 'next/font/google';
|
|
3
|
-
|
|
4
|
-
import '@san-siva/stylekit/styles/globals.scss';
|
|
5
|
-
import '@san-siva/blogkit/styles.css';
|
|
6
|
-
import styles from '@san-siva/stylekit/styles/index.module.scss';
|
|
7
|
-
|
|
8
|
-
const montserrat = Montserrat({
|
|
9
|
-
subsets: ['latin'] as const,
|
|
10
|
-
weight: ['400', '500', '600', '700', '800'] as const,
|
|
11
|
-
style: ['normal', 'italic'] as const,
|
|
12
|
-
variable: '--font-montserrat',
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
const rubik = Rubik({
|
|
16
|
-
subsets: ['latin'] as const,
|
|
17
|
-
weight: ['300', '400', '500', '600', '700', '800', '900'] as const,
|
|
18
|
-
style: ['normal', 'italic'] as const,
|
|
19
|
-
variable: '--font-rubik',
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
const jetbrainsMono = JetBrains_Mono({
|
|
23
|
-
subsets: ['latin'] as const,
|
|
24
|
-
weight: ['400', '500', '600', '700'] as const,
|
|
25
|
-
style: ['normal', 'italic'] as const,
|
|
26
|
-
variable: '--font-jetbrains-mono',
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
export const metadata: Metadata = {
|
|
30
|
-
title: 'Blogkit MD',
|
|
31
|
-
description: 'Markdown to JSX blog post renderer',
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
const RootLayout = ({ children }: { children: React.ReactNode }) => (
|
|
35
|
-
<html lang="en">
|
|
36
|
-
<body
|
|
37
|
-
className={`${montserrat.variable} ${rubik.variable} ${jetbrainsMono.variable}`}
|
|
38
|
-
>
|
|
39
|
-
<div className={`${styles.page}`}>{children}</div>
|
|
40
|
-
</body>
|
|
41
|
-
</html>
|
|
42
|
-
);
|
|
43
|
-
|
|
44
|
-
export default RootLayout;
|
package/app/page.tsx
DELETED
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
import { Blog, BlogHeader, Callout } from '@san-siva/blogkit';
|
|
2
|
-
|
|
3
|
-
import { readMarkdownFile } from '@/hooks/readMarkdownFile';
|
|
4
|
-
import { MarkdownSections } from '@/utils/renderMarkdown';
|
|
5
|
-
|
|
6
|
-
const Page = async () => {
|
|
7
|
-
const result = await readMarkdownFile(process.env.MARKDOWN_FILE);
|
|
8
|
-
|
|
9
|
-
if (!result.success) {
|
|
10
|
-
return (
|
|
11
|
-
<Blog>
|
|
12
|
-
<Callout type="warning">{result.error}</Callout>
|
|
13
|
-
</Blog>
|
|
14
|
-
);
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
const { rendered } = result;
|
|
18
|
-
|
|
19
|
-
return (
|
|
20
|
-
<Blog>
|
|
21
|
-
{rendered.pageTitle && <BlogHeader title={[rendered.pageTitle]} desc={[]} />}
|
|
22
|
-
<MarkdownSections rendered={rendered} />
|
|
23
|
-
</Blog>
|
|
24
|
-
);
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
export default Page;
|
package/next.config.ts
DELETED
package/scripts/dev.ts
DELETED
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
import { exec, spawn } from 'node:child_process';
|
|
2
|
-
import { stat, writeFileSync } from 'node:fs';
|
|
3
|
-
import path from 'node:path';
|
|
4
|
-
|
|
5
|
-
const fileArgument = process.argv.find(argument => argument.startsWith('--file='));
|
|
6
|
-
const markdownFile = fileArgument ? fileArgument.replace('--file=', '') : 'data/test.md';
|
|
7
|
-
|
|
8
|
-
const markdownPath = path.join(process.cwd(), markdownFile);
|
|
9
|
-
const triggerPath = path.join(process.cwd(), 'utils/devReloadTrigger.ts');
|
|
10
|
-
|
|
11
|
-
// Write on startup so fresh clones have the file before Next.js compiles.
|
|
12
|
-
writeFileSync(triggerPath, `export const reloadTrigger = '${Date.now()}';\n`);
|
|
13
|
-
|
|
14
|
-
let lastMtime: number | null = null;
|
|
15
|
-
|
|
16
|
-
const checkMarkdownFile = () => {
|
|
17
|
-
stat(markdownPath, (error, stats) => {
|
|
18
|
-
if (error) return;
|
|
19
|
-
const { mtimeMs } = stats;
|
|
20
|
-
if (lastMtime !== null && mtimeMs !== lastMtime) {
|
|
21
|
-
writeFileSync(triggerPath, `export const reloadTrigger = '${mtimeMs}';\n`);
|
|
22
|
-
}
|
|
23
|
-
lastMtime = mtimeMs;
|
|
24
|
-
});
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
setInterval(checkMarkdownFile, 500);
|
|
28
|
-
checkMarkdownFile();
|
|
29
|
-
|
|
30
|
-
const child = spawn('next', ['dev'], {
|
|
31
|
-
env: { ...process.env, MARKDOWN_FILE: markdownFile },
|
|
32
|
-
stdio: ['inherit', 'pipe', 'inherit'],
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
let browserOpened = false;
|
|
36
|
-
|
|
37
|
-
child.stdout?.on('data', (chunk: Buffer) => {
|
|
38
|
-
process.stdout.write(chunk);
|
|
39
|
-
if (!browserOpened) {
|
|
40
|
-
const match = chunk.toString().match(/http:\/\/localhost:\d+/);
|
|
41
|
-
if (match) {
|
|
42
|
-
browserOpened = true;
|
|
43
|
-
exec(`open ${match[0]}`);
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
child.on('exit', code => {
|
|
49
|
-
process.exit(code ?? 0);
|
|
50
|
-
});
|