@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 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,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
- | 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 |
109
- | Blockquote (warning) | `> ~text` renders as warning callout |
110
- | Blockquote (error) | `> !text` — renders as error 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 | `\| 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—**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**.
147
169
 
148
- | Markdown | Layout Behavior |
149
- | :----------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------- |
150
- | `# H1` | **Page Title.** Sets the main article title. Does not generate a structural section block. |
151
- | `## H2` | **Main Section.** Creates a new, top-level `BlogSection`. |
152
- | `### H3` | **Subsection.** Nests cleanly within the currently active `H2` section. |
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—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.
157
177
 
158
- ### Special Layout Rules
178
+ ### The Nesting Logic
159
179
 
160
- 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):
161
181
 
162
- | Rule | Behavior |
163
- | ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
164
- | 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. |
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
- 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:
171
189
 
172
190
  ```markdown
173
- # My Awesome Blog Post
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
- #### Note on performance:
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
- This text becomes an introductory, untitled section.
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
- #### Prerequisites
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
- #### Note on performance:
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
- ## The Results
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
- | Syntax | Callout Type | Example |
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
- ## License
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
- ## About
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)
@@ -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
  );
@@ -1,27 +1,25 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
 
4
- import '@/utils/devReloadTrigger';
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.join(process.cwd(), filePath);
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
@@ -1 +1,3 @@
1
1
  export { default as BlogPost } from './components/BlogPost';
2
+ export { readMarkdownFile } from './hooks/readMarkdownFile';
3
+ export { MarkdownSections } from './utils/renderMarkdown';
package/package.json CHANGED
@@ -1,15 +1,13 @@
1
1
  {
2
2
  "name": "@san-siva/blogkit-md",
3
- "version": "0.1.1",
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
- "dev": "tsx scripts/dev.ts",
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
- "unified": "^11.0.5"
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
+ });
@@ -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
  };
@@ -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: RootContent,
20
- key: number,
21
- nextNode?: RootContent,
22
- inList = false
23
- ): 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 {
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
- const marginClass =
30
- nextNode?.type === 'paragraph'
31
- ? 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
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(child, index, strippedChildren[index + 1])
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(child as RootContent, index, undefined, true)
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) => renderNode(node, index, nodes[index + 1]));
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: number): React.ReactNode {
186
+ function renderSection(section: Section, key = -1): React.ReactNode {
163
187
  return (
164
- <BlogSection key={key} title={section.title}>
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 { pageTitle, beforeFirstHeading, textBeforeFirstSection, sections } =
182
- groupSections(ast.children);
183
-
202
+ const grouped = groupSections(ast.children);
184
203
  return {
185
- pageTitle,
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.0",
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
@@ -1,5 +0,0 @@
1
- import type { NextConfig } from 'next';
2
-
3
- const nextConfig: NextConfig = {};
4
-
5
- export default nextConfig;
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
- });