@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.
- package/.prettierrc.json +9 -0
- package/LICENSE +21 -0
- package/README.md +236 -0
- package/app/layout.tsx +44 -0
- package/app/page.tsx +27 -0
- package/components/BlogPost.tsx +54 -0
- package/eslint/common.config.ts +29 -0
- package/eslint/react.config.ts +20 -0
- package/eslint/utilities.ts +236 -0
- package/eslint.config.ts +8 -0
- package/hooks/readMarkdownFile.ts +44 -0
- package/index.ts +1 -0
- package/next.config.ts +5 -0
- package/package.json +54 -0
- package/scripts/dev.ts +50 -0
- package/tsconfig.json +29 -0
- package/utils/extractText.ts +11 -0
- package/utils/groupSections.ts +114 -0
- package/utils/parseMarkdown.ts +9 -0
- package/utils/renderMarkdown.tsx +163 -0
- package/utils/renderPhrasingContent.tsx +98 -0
- package/website/app/data.ts +46 -0
- package/website/app/layout.tsx +74 -0
- package/website/app/page.tsx +30 -0
- package/website/next.config.ts +9 -0
- package/website/package-lock.json +3273 -0
- package/website/package.json +25 -0
- package/website/tsconfig.json +30 -0
|
@@ -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
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('&', '&')
|
|
10
|
+
.replaceAll('<', '<')
|
|
11
|
+
.replaceAll('>', '>');
|
|
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
|
+
};
|