@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 CHANGED
@@ -1,8 +1,11 @@
1
- # blogkit-md
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
- > Note: [This](https://blogkit-md.santhoshsiva.dev) blog post is translated from the `README.md` file in the [`blogkit-md`](https://github.com/san-siva/blogkit-md) repository.
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 | Syntax |
93
- | --------------- | ---------------------------------- |
94
- | Headings | `# H1` `## H2` `### H3` `#### H4` |
95
- | Paragraph | Plain text |
96
- | Hard line break | Two spaces at end of line |
97
- | Bold | `**bold**` |
98
- | Italic | `_italic_` |
99
- | Inline code | `` `code` `` |
100
- | Link | `[text](url)` |
101
- | Image | `![alt](url)` |
102
- | Ordered list | `1. item` |
103
- | Unordered list | `- item` |
104
- | Table | GFM table syntax |
105
- | Code block | ` ```lang ` |
106
- | Mermaid diagram | ` ```mermaid ` |
107
- | Thematic break | `---` |
108
- | Blockquote | `> text` renders as info callout |
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 | `![alt](url)` |
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—**they are the architectural blueprint for your post**. Each heading level acts as a layout trigger, directly controlling how `BlogSection` components are generated, nested, or promoted.
168
+ In `blogkit-md`, headings aren't just for changing font sizes — **they are the architectural blueprint for your post**.
145
169
 
146
- | Markdown | Layout Behavior |
147
- | :----------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------- |
148
- | `# H1` | **Page Title.** Sets the main article title. Does not generate a structural section block. |
149
- | `## H2` | **Main Section.** Creates a new, top-level `BlogSection`. |
150
- | `### H3` | **Subsection.** Nests cleanly within the currently active `H2` section. |
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—such as paragraphs, lists, and code blocks—automatically flows into the most recently opened section or subsection.
176
+ > Standard content — paragraphs, lists, code blocks — flows into the most recently opened section or subsection.
155
177
 
156
- ### Special Layout Rules
178
+ ### The Nesting Logic
157
179
 
158
- Because `blogkit-md` is optimized for blog readability, it includes smart fallbacks to ensure your layout looks great even in edge cases:
180
+ The layout is determined entirely by heading level (depth):
159
181
 
160
- | Rule | Behavior |
161
- | ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
162
- | Strictly Deepening Hierarchy | Headings within a section must always go deeper (e.g., `H2 → H3 → H4`). If the hierarchy reverses—like an `H3` appearing after an `H4`—the nesting breaks, and the `H3` is promoted to a brand-new top-level section. |
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
- Let's put those layout rules into practice. Here is how a standard markdown document translates into a blog layout:
188
+ Here is how a standard markdown document maps to blog layout:
169
189
 
170
190
  ```markdown
171
- # My Awesome Blog Post
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
- #### Note on performance:
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
- This text becomes an introductory, untitled section.
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
- #### Prerequisites
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
- #### Note on performance:
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
- ## The Results
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
- ## License
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
- ## About
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
- {rendered.pageTitle && <BlogHeader title={[rendered.pageTitle]} desc={[]} />}
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
  );
@@ -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
- {rendered.pageTitle && (
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.0",
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
- "unified": "^11.0.5"
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
+ });
@@ -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
- export type GroupedSections = {
12
- pageTitle: string | null;
13
- beforeFirstHeading: RootContent[];
14
- textBeforeFirstSection: RootContent[];
15
- sections: Section[];
16
- };
17
-
18
- const makeSection = (title: string): Section => ({
19
- title,
20
- nodes: [],
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
- if (currentSubsection) {
99
- currentSubsection.nodes.push(node);
100
- continue;
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
- if (currentSection) {
104
- currentSection.nodes.push(node);
105
- continue;
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
- if (afterH1) {
109
- textBeforeFirstSection.push(node);
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 { pageTitle, beforeFirstHeading, textBeforeFirstSection, sections };
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
  };
@@ -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 const parseMarkdown = (content: string): Root => {
7
- const processor = unified().use(remarkParse).use(remarkGfm);
8
- return processor.parse(content) as Root;
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
  };
@@ -1,6 +1,12 @@
1
1
  import React from 'react';
2
2
 
3
- import { BlogSection, Callout, CodeBlock, Mermaid, Table } from '@san-siva/blogkit';
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: RootContent,
14
- key: number,
15
- nextNode?: RootContent,
16
- inList = false
17
- ): React.ReactNode {
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
- const marginClass =
24
- nextNode?.type === 'paragraph'
25
- ? styles['margin-bottom--1']
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="info" hasMarginUp hasMarginDown>
89
- {children.map((child, index) =>
90
- renderNode(child, index, children[index + 1])
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(child as RootContent, index, undefined, true)
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) => renderNode(node, index, nodes[index + 1]));
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: number): React.ReactNode {
186
+ function renderSection(section: Section, key = -1): React.ReactNode {
120
187
  return (
121
- <BlogSection key={key} title={section.title}>
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 { pageTitle, beforeFirstHeading, textBeforeFirstSection, sections } =
139
- groupSections(ast.children);
140
-
202
+ const grouped = groupSections(ast.children);
141
203
  return {
142
- pageTitle,
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.0",
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": {