@san-siva/blogkit-md 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,44 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ import '@/utils/devReloadTrigger';
5
+ import { parseMarkdown } from '@/utils/parseMarkdown';
6
+ import type { RenderedMarkdown } from '@/utils/renderMarkdown';
7
+ import { renderMarkdownAst } from '@/utils/renderMarkdown';
8
+
9
+ type MarkdownFileResult =
10
+ | { success: true; rendered: RenderedMarkdown }
11
+ | { success: false; error: string };
12
+
13
+ export const readMarkdownFile = async (
14
+ filePath: string | undefined
15
+ ): Promise<MarkdownFileResult> => {
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
+ };
22
+ }
23
+
24
+ const absolutePath = path.join(process.cwd(), filePath);
25
+
26
+ let content: string;
27
+ try {
28
+ content = await readFile(absolutePath, 'utf8');
29
+ } catch {
30
+ return {
31
+ success: false,
32
+ error: `Could not read file: "${filePath}". Make sure the path is correct and the file exists.`,
33
+ };
34
+ }
35
+
36
+ if (!content.trim()) {
37
+ return { success: false, error: `File "${filePath}" is empty.` };
38
+ }
39
+
40
+ const ast = parseMarkdown(content);
41
+ const rendered = renderMarkdownAst(ast);
42
+
43
+ return { success: true, rendered };
44
+ };
package/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { default as BlogPost } from './components/BlogPost';
package/next.config.ts ADDED
@@ -0,0 +1,5 @@
1
+ import type { NextConfig } from 'next';
2
+
3
+ const nextConfig: NextConfig = {};
4
+
5
+ export default nextConfig;
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@san-siva/blogkit-md",
3
+ "version": "0.1.0",
4
+ "description": "Converts markdown files into JSX blog posts for Blogkit",
5
+ "main": "index.ts",
6
+ "exports": {
7
+ ".": "./index.ts"
8
+ },
9
+ "scripts": {
10
+ "dev": "tsx scripts/dev.ts",
11
+ "build": "MARKDOWN_FILE=data/test.md next build",
12
+ "start": "next start",
13
+ "lint": "eslint . --config eslint.config.ts",
14
+ "fix": "eslint . --config eslint.config.ts --fix"
15
+ },
16
+ "dependencies": {
17
+ "@san-siva/blogkit": "^1.1.20",
18
+ "@san-siva/stylekit": "^1.0.8",
19
+ "next": "^16.0.10",
20
+ "react": "^19.0.0",
21
+ "react-dom": "^19.0.0",
22
+ "remark-gfm": "^4.0.1",
23
+ "schema-dts": "^1.1.2",
24
+ "remark-parse": "^11.0.0",
25
+ "sass": "^1.95.1",
26
+ "unified": "^11.0.5"
27
+ },
28
+ "devDependencies": {
29
+ "@eslint/js": "^9.0.0",
30
+ "@types/eslint": "^9.6.1",
31
+ "@types/eslint__js": "^9.14.0",
32
+ "@types/mdast": "^4.0.4",
33
+ "@types/node": "^22",
34
+ "@types/react": "^19",
35
+ "@types/react-dom": "^19",
36
+ "eslint": "^9.0.0",
37
+ "eslint-config-next": "^16.0.10",
38
+ "eslint-config-prettier": "^10.0.0",
39
+ "eslint-import-resolver-typescript": "^4.0.0",
40
+ "eslint-plugin-import": "^2.0.0",
41
+ "eslint-plugin-jsx-a11y": "^6.10.0",
42
+ "eslint-plugin-no-await-in-promise": "^3.0.0",
43
+ "eslint-plugin-prettier": "^5.0.0",
44
+ "eslint-plugin-react": "^7.0.0",
45
+ "eslint-plugin-react-hooks": "^5.0.0",
46
+ "eslint-plugin-simple-import-sort": "^12.0.0",
47
+ "eslint-plugin-unicorn": "^62.0.0",
48
+ "jiti": "^2.0.0",
49
+ "prettier": "^3.0.0",
50
+ "tsx": "^4.0.0",
51
+ "typescript": "^5.0.0",
52
+ "typescript-eslint": "^8.0.0"
53
+ }
54
+ }
package/scripts/dev.ts ADDED
@@ -0,0 +1,50 @@
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
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2017",
4
+ "lib": ["dom", "dom.iterable", "esnext"],
5
+ "allowJs": true,
6
+ "skipLibCheck": true,
7
+ "strict": true,
8
+ "noEmit": true,
9
+ "esModuleInterop": true,
10
+ "module": "esnext",
11
+ "moduleResolution": "bundler",
12
+ "resolveJsonModule": true,
13
+ "isolatedModules": true,
14
+ "jsx": "react-jsx",
15
+ "incremental": true,
16
+ "plugins": [{ "name": "next" }],
17
+ "paths": {
18
+ "@/*": ["./*"]
19
+ }
20
+ },
21
+ "include": [
22
+ "next-env.d.ts",
23
+ "**/*.ts",
24
+ "**/*.tsx",
25
+ ".next/types/**/*.ts",
26
+ ".next/dev/types/**/*.ts"
27
+ ],
28
+ "exclude": ["node_modules", "dist"]
29
+ }
@@ -0,0 +1,11 @@
1
+ import type { PhrasingContent } from 'mdast';
2
+
3
+ export const extractText = (nodes: PhrasingContent[]): string =>
4
+ nodes
5
+ .map(node => {
6
+ if (node.type === 'text') return node.value;
7
+ if ('children' in node)
8
+ return extractText(node.children as PhrasingContent[]);
9
+ return '';
10
+ })
11
+ .join('');
@@ -0,0 +1,114 @@
1
+ import type { RootContent } from 'mdast';
2
+
3
+ import { extractText } from './extractText';
4
+
5
+ export type Section = {
6
+ title: string;
7
+ nodes: RootContent[];
8
+ subsections: Section[];
9
+ };
10
+
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
+ }
97
+
98
+ if (currentSubsection) {
99
+ currentSubsection.nodes.push(node);
100
+ continue;
101
+ }
102
+
103
+ if (currentSection) {
104
+ currentSection.nodes.push(node);
105
+ continue;
106
+ }
107
+
108
+ if (afterH1) {
109
+ textBeforeFirstSection.push(node);
110
+ }
111
+ }
112
+
113
+ return { pageTitle, beforeFirstHeading, textBeforeFirstSection, sections };
114
+ };
@@ -0,0 +1,9 @@
1
+ import type { Root } from 'mdast';
2
+ import remarkGfm from 'remark-gfm';
3
+ import remarkParse from 'remark-parse';
4
+ import { unified } from 'unified';
5
+
6
+ export const parseMarkdown = (content: string): Root => {
7
+ const processor = unified().use(remarkParse).use(remarkGfm);
8
+ return processor.parse(content) as Root;
9
+ };
@@ -0,0 +1,163 @@
1
+ import React from 'react';
2
+
3
+ import { BlogSection, Callout, CodeBlock, Mermaid, Table } from '@san-siva/blogkit';
4
+ import type { Root, RootContent } from 'mdast';
5
+
6
+ import type { Section } from './groupSections';
7
+ import { groupSections } from './groupSections';
8
+ import { renderPhrasingContent } from './renderPhrasingContent';
9
+
10
+ import styles from '@san-siva/stylekit/styles/index.module.scss';
11
+
12
+ function renderNode(
13
+ node: RootContent,
14
+ key: number,
15
+ nextNode?: RootContent,
16
+ inList = false
17
+ ): React.ReactNode {
18
+ switch (node.type) {
19
+ case 'paragraph': {
20
+ if (inList) {
21
+ return <p key={key}>{renderPhrasingContent(node.children)}</p>;
22
+ }
23
+ const marginClass =
24
+ nextNode?.type === 'paragraph'
25
+ ? styles['margin-bottom--1']
26
+ : styles['margin-bottom--2'];
27
+ return (
28
+ <p key={key} className={marginClass}>
29
+ {renderPhrasingContent(node.children)}
30
+ </p>
31
+ );
32
+ }
33
+ case 'code': {
34
+ if (node.lang === 'mermaid') {
35
+ return (
36
+ <Mermaid
37
+ key={key}
38
+ id={`mermaid-${key}`}
39
+ code={node.value}
40
+ hasMarginUp
41
+ hasMarginDown
42
+ />
43
+ );
44
+ }
45
+ return (
46
+ <CodeBlock
47
+ key={key}
48
+ language={node.lang ?? 'text'}
49
+ code={node.value}
50
+ hasMarginUp
51
+ hasMarginDown
52
+ />
53
+ );
54
+ }
55
+ case 'heading': {
56
+ return (
57
+ <p key={key} className={styles['margin-bottom--2']}>
58
+ <strong>{renderPhrasingContent(node.children)}</strong>
59
+ </p>
60
+ );
61
+ }
62
+ case 'thematicBreak': {
63
+ return <hr key={key} className={styles['margin-bottom--2']} />;
64
+ }
65
+ case 'table': {
66
+ const [headerRow, ...bodyRows] = node.children;
67
+ const headers = headerRow?.children.map(cell =>
68
+ renderPhrasingContent(cell.children)
69
+ );
70
+ const rows = bodyRows.map(row =>
71
+ row.children.map((cell, index) => (
72
+ <p key={index}>{renderPhrasingContent(cell.children)}</p>
73
+ ))
74
+ );
75
+ return (
76
+ <Table
77
+ key={key}
78
+ headers={headers}
79
+ rows={rows}
80
+ hasMarginUp
81
+ hasMarginDown
82
+ />
83
+ );
84
+ }
85
+ case 'blockquote': {
86
+ const children = node.children as RootContent[];
87
+ return (
88
+ <Callout key={key} type="info" hasMarginUp hasMarginDown>
89
+ {children.map((child, index) =>
90
+ renderNode(child, index, children[index + 1])
91
+ )}
92
+ </Callout>
93
+ );
94
+ }
95
+ case 'list': {
96
+ const Tag = node.ordered ? 'ol' : 'ul';
97
+ return (
98
+ <Tag key={key}>
99
+ {node.children.map((item, index) => (
100
+ <li key={index}>
101
+ {item.children.map((child, index) =>
102
+ renderNode(child as RootContent, index, undefined, true)
103
+ )}
104
+ </li>
105
+ ))}
106
+ </Tag>
107
+ );
108
+ }
109
+ default: {
110
+ return null;
111
+ }
112
+ }
113
+ }
114
+
115
+ function renderNodes(nodes: RootContent[]): React.ReactNode[] {
116
+ return nodes.map((node, index) => renderNode(node, index, nodes[index + 1]));
117
+ }
118
+
119
+ function renderSection(section: Section, key: number): React.ReactNode {
120
+ return (
121
+ <BlogSection key={key} title={section.title}>
122
+ {renderNodes(section.nodes)}
123
+ {section.subsections.map((subsection, index) =>
124
+ renderSection(subsection, index)
125
+ )}
126
+ </BlogSection>
127
+ );
128
+ }
129
+
130
+ export type RenderedMarkdown = {
131
+ pageTitle: string | null;
132
+ beforeFirstHeading: React.ReactNode[];
133
+ textBeforeFirstSection: React.ReactNode[];
134
+ sections: React.ReactNode[];
135
+ };
136
+
137
+ export const renderMarkdownAst = (ast: Root): RenderedMarkdown => {
138
+ const { pageTitle, beforeFirstHeading, textBeforeFirstSection, sections } =
139
+ groupSections(ast.children);
140
+
141
+ return {
142
+ pageTitle,
143
+ beforeFirstHeading: renderNodes(beforeFirstHeading),
144
+ textBeforeFirstSection: renderNodes(textBeforeFirstSection),
145
+ sections: sections.map((section, index) => renderSection(section, index)),
146
+ };
147
+ };
148
+
149
+ export const MarkdownSections = ({
150
+ rendered,
151
+ }: {
152
+ 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
+ );
@@ -0,0 +1,98 @@
1
+ import React from 'react';
2
+
3
+ import type { PhrasingContent } from 'mdast';
4
+
5
+ import styles from '@san-siva/stylekit/styles/index.module.scss';
6
+
7
+ function escapeHtml(text: string): string {
8
+ return text
9
+ .replaceAll('&', '&amp;')
10
+ .replaceAll('<', '&lt;')
11
+ .replaceAll('>', '&gt;');
12
+ }
13
+
14
+ function toHtmlString(nodes: PhrasingContent[]): string {
15
+ return nodes
16
+ .map(node => {
17
+ switch (node.type) {
18
+ case 'text': {
19
+ return escapeHtml(node.value);
20
+ }
21
+ case 'html': {
22
+ return node.value;
23
+ }
24
+ case 'strong': {
25
+ return `<strong>${toHtmlString(node.children)}</strong>`;
26
+ }
27
+ case 'emphasis': {
28
+ return `<em>${toHtmlString(node.children)}</em>`;
29
+ }
30
+ case 'inlineCode': {
31
+ return `<code>${escapeHtml(node.value)}</code>`;
32
+ }
33
+ case 'link': {
34
+ return `<a href="${escapeHtml(node.url)}">${toHtmlString(node.children)}</a>`;
35
+ }
36
+ case 'break': {
37
+ return '<br>';
38
+ }
39
+ case 'image': {
40
+ return `<img src="${escapeHtml(node.url)}" alt="${escapeHtml(node.alt ?? '')}" style="max-width:300px">`;
41
+ }
42
+ default: {
43
+ return '';
44
+ }
45
+ }
46
+ })
47
+ .join('');
48
+ }
49
+
50
+ export function renderPhrasingContent(
51
+ nodes: PhrasingContent[]
52
+ ): React.ReactNode {
53
+ if (nodes.some(node => node.type === 'html')) {
54
+ return <span dangerouslySetInnerHTML={{ __html: toHtmlString(nodes) }} />;
55
+ }
56
+
57
+ return nodes.map((node, index) => {
58
+ switch (node.type) {
59
+ case 'text': {
60
+ return node.value;
61
+ }
62
+ case 'strong': {
63
+ return (
64
+ <strong key={index}>{renderPhrasingContent(node.children)}</strong>
65
+ );
66
+ }
67
+ case 'emphasis': {
68
+ return <em key={index}>{renderPhrasingContent(node.children)}</em>;
69
+ }
70
+ case 'inlineCode': {
71
+ return <code key={index}>{node.value}</code>;
72
+ }
73
+ case 'link': {
74
+ return (
75
+ <a key={index} href={node.url} className={styles['a--highlighted']}>
76
+ {renderPhrasingContent(node.children)}
77
+ </a>
78
+ );
79
+ }
80
+ case 'break': {
81
+ return <br key={index} />;
82
+ }
83
+ case 'image': {
84
+ return (
85
+ <img
86
+ key={index}
87
+ src={node.url}
88
+ alt={node.alt ?? ''}
89
+ style={{ maxWidth: '300px' }}
90
+ />
91
+ );
92
+ }
93
+ default: {
94
+ return null;
95
+ }
96
+ }
97
+ });
98
+ }
@@ -0,0 +1,46 @@
1
+ export const SITE_URL = 'https://blogkit-md.santhoshsiva.dev';
2
+
3
+ export type PageMeta = {
4
+ title: string;
5
+ desc: string;
6
+ publishedOn: string;
7
+ isoDate: string;
8
+ keywords: string[];
9
+ };
10
+
11
+ export const generateMetadata = (meta: PageMeta) => ({
12
+ title: meta.title,
13
+ description: meta.desc,
14
+ keywords: meta.keywords,
15
+ authors: [{ name: 'Santhosh Siva' }],
16
+ alternates: {
17
+ canonical: `${SITE_URL}/`,
18
+ },
19
+ openGraph: {
20
+ title: meta.title,
21
+ description: meta.desc,
22
+ type: 'website' as const,
23
+ url: `${SITE_URL}/`,
24
+ },
25
+ twitter: {
26
+ card: 'summary_large_image' as const,
27
+ title: meta.title,
28
+ description: meta.desc,
29
+ },
30
+ });
31
+
32
+ export const BLOGKIT_MD: PageMeta = {
33
+ title: 'blogkit-md — Markdown to Blog',
34
+ desc: 'A Next.js tool that converts standard markdown files into rendered blog posts for Blogkit.',
35
+ publishedOn: 'March 18, 2026',
36
+ isoDate: '2026-03-18',
37
+ keywords: [
38
+ 'markdown',
39
+ 'blog',
40
+ 'next.js',
41
+ 'blogkit',
42
+ 'react',
43
+ 'remark',
44
+ 'mdast',
45
+ ],
46
+ };