@san-siva/blogkit-md 0.2.9 → 0.3.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
@@ -79,13 +79,12 @@ description: A short description shown below the title
79
79
  | Image | `![alt](url)` |
80
80
  | Ordered list | `1. item` |
81
81
  | Unordered list | `- item` |
82
+ | Task list | `- [ ] item` / `- [x] item` |
82
83
  | Table | `\| col \| col \|` — headers and rows only |
83
84
  | Code block | ` ```lang ` |
84
85
  | Mermaid diagram | ` ```mermaid ` |
85
86
  | Thematic break | `---` |
86
- | Blockquote | `> text` — renders as info callout |
87
- | Blockquote (warning) | `> ~text` — renders as warning callout |
88
- | Blockquote (error) | `> !text` — renders as error callout |
87
+ | Blockquote / Callout | `> text` or `> [!TYPE]` — renders as callout |
89
88
 
90
89
  ## Philosophy
91
90
 
@@ -215,15 +214,23 @@ Result content.
215
214
 
216
215
  ### Callouts
217
216
 
218
- Blockquotes are rendered as styled callout banners. The callout type is controlled by a prefix on the first word of the quote.
217
+ Blockquotes are rendered as styled callout banners. Plain blockquotes render as info callouts. Use GitHub-style alert syntax to control the type:
219
218
 
220
- > This is an info callout. Use `> text` for general information.
219
+ | Syntax | Callout type |
220
+ | :------------- | :----------- |
221
+ | `[!NOTE]` | info |
222
+ | `[!TIP]` | info |
223
+ | `[!IMPORTANT]` | info |
224
+ | `[!WARNING]` | warning |
225
+ | `[!CAUTION]` | error |
221
226
 
222
- > ~This is a warning callout. Use `> ~text` to flag caution.
223
-
224
- > !This is an error callout. Use `> !text` for errors or destructive actions.
227
+ ```markdown
228
+ > [!WARNING]
229
+ >
230
+ > Do not clone this repository into system folders.
231
+ ```
225
232
 
226
- The prefix character is stripped from the rendered output — only the callout style changes.
233
+ The `[!TYPE]` line is stripped from the rendered output.
227
234
 
228
235
  ## Want more customization?
229
236
 
@@ -238,3 +245,7 @@ Contributions are welcome!
238
245
 
239
246
  - **Author:** [Santhosh Siva](https://www.santhoshsiva.dev)
240
247
  - **License:** [MIT](https://github.com/san-siva/blogkit-md/blob/main/LICENSE)
248
+
249
+ > [!WARNING]
250
+ >
251
+ > This project is in early development.
package/dist/index.js CHANGED
@@ -29,6 +29,25 @@ import remarkGfm from "remark-gfm";
29
29
  import remarkParse from "remark-parse";
30
30
  import { unified } from "unified";
31
31
  import { parse as parseYaml } from "yaml";
32
+
33
+ // src/utils/extractText.ts
34
+ var extractText = (nodes) => nodes.map((node) => {
35
+ if (node.type === "text") return node.value;
36
+ if ("children" in node)
37
+ return extractText(node.children);
38
+ return "";
39
+ }).join("");
40
+
41
+ // src/utils/parseMarkdown.ts
42
+ var consumeH1Title = (ast, frontmatter) => {
43
+ var _a;
44
+ if (!frontmatter.title && ((_a = ast.children[0]) == null ? void 0 : _a.type) === "heading" && ast.children[0].depth === 1) {
45
+ const title = extractText(ast.children[0].children);
46
+ ast.children.shift();
47
+ return __spreadProps(__spreadValues({}, frontmatter), { title });
48
+ }
49
+ return frontmatter;
50
+ };
32
51
  var parseMarkdown = (content) => {
33
52
  var _a, _b;
34
53
  const processor = unified().use(remarkParse).use(remarkGfm).use(remarkFrontmatter, ["yaml"]);
@@ -44,6 +63,7 @@ var parseMarkdown = (content) => {
44
63
  };
45
64
  ast.children.shift();
46
65
  }
66
+ frontmatter = consumeH1Title(ast, frontmatter);
47
67
  return { ast, frontmatter };
48
68
  };
49
69
 
@@ -51,19 +71,12 @@ var parseMarkdown = (content) => {
51
71
  import {
52
72
  BlogSection,
53
73
  Callout,
74
+ CheckList,
54
75
  CodeBlock,
55
76
  Mermaid,
56
77
  Table
57
78
  } from "@san-siva/blogkit";
58
79
 
59
- // src/utils/extractText.ts
60
- var extractText = (nodes) => nodes.map((node) => {
61
- if (node.type === "text") return node.value;
62
- if ("children" in node)
63
- return extractText(node.children);
64
- return "";
65
- }).join("");
66
-
67
80
  // src/utils/groupSections.ts
68
81
  var consumeNode = (nodes, index, sections, section) => {
69
82
  const node = nodes.at(index);
@@ -216,7 +229,8 @@ function renderNode({
216
229
  key,
217
230
  nextNode,
218
231
  inList = false,
219
- inCallout = false
232
+ inCallout = false,
233
+ counters
220
234
  }) {
221
235
  var _a;
222
236
  switch (node.type) {
@@ -231,10 +245,11 @@ function renderNode({
231
245
  }
232
246
  case "code": {
233
247
  if (node.lang === "mermaid") {
248
+ const mermaidId = counters.mermaid++;
234
249
  return /* @__PURE__ */ jsx2(
235
250
  Mermaid,
236
251
  {
237
- id: `mermaid-${key}`,
252
+ id: `mermaid-${mermaidId}`,
238
253
  code: node.value,
239
254
  hasMarginUp: true,
240
255
  hasMarginDown: true
@@ -286,30 +301,11 @@ function renderNode({
286
301
  if ((firstChild == null ? void 0 : firstChild.type) === "paragraph") {
287
302
  const firstInline = firstChild.children[0];
288
303
  if ((firstInline == null ? void 0 : firstInline.type) === "text") {
289
- if (firstInline.value.startsWith("!")) {
290
- calloutType = "error";
291
- const trimmed = firstInline.value.slice(1).trimStart();
292
- strippedChildren = [
293
- __spreadProps(__spreadValues({}, firstChild), {
294
- children: [
295
- __spreadProps(__spreadValues({}, firstInline), { value: trimmed }),
296
- ...firstChild.children.slice(1)
297
- ]
298
- }),
299
- ...children.slice(1)
300
- ];
301
- } else if (firstInline.value.startsWith("~")) {
302
- calloutType = "warning";
303
- const trimmed = firstInline.value.slice(1).trimStart();
304
- strippedChildren = [
305
- __spreadProps(__spreadValues({}, firstChild), {
306
- children: [
307
- __spreadProps(__spreadValues({}, firstInline), { value: trimmed }),
308
- ...firstChild.children.slice(1)
309
- ]
310
- }),
311
- ...children.slice(1)
312
- ];
304
+ const githubAlertMatch = /^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]$/i.exec(firstInline.value);
305
+ if (githubAlertMatch) {
306
+ const alertType = githubAlertMatch[1].toUpperCase();
307
+ calloutType = alertType === "WARNING" ? "warning" : alertType === "CAUTION" ? "error" : "info";
308
+ strippedChildren = children.slice(1);
313
309
  }
314
310
  }
315
311
  }
@@ -318,17 +314,30 @@ function renderNode({
318
314
  node: child,
319
315
  key: index,
320
316
  nextNode: strippedChildren[index + 1],
321
- inCallout: true
317
+ inCallout: true,
318
+ counters
322
319
  })
323
320
  ) }, key);
324
321
  }
325
322
  case "list": {
323
+ const isTaskList = node.children.every((item) => item.checked !== null && item.checked !== void 0);
324
+ if (isTaskList) {
325
+ const items = node.children.map((item, index) => ({
326
+ id: String(index),
327
+ isChecked: item.checked === true,
328
+ children: item.children.map(
329
+ (child, childIndex) => renderNode({ node: child, key: childIndex, inList: true, counters })
330
+ )
331
+ }));
332
+ return /* @__PURE__ */ jsx2(CheckList, { items, hasMarginUp: true, hasMarginDown: true }, key);
333
+ }
326
334
  const Tag = node.ordered ? "ol" : "ul";
327
335
  return /* @__PURE__ */ jsx2(Tag, { className: styles2["margin-bottom--2"], children: node.children.map((item, index) => /* @__PURE__ */ jsx2("li", { children: item.children.map(
328
336
  (child, index2) => renderNode({
329
337
  node: child,
330
338
  key: index2,
331
- inList: true
339
+ inList: true,
340
+ counters
332
341
  })
333
342
  ) }, index)) }, key);
334
343
  }
@@ -337,24 +346,25 @@ function renderNode({
337
346
  }
338
347
  }
339
348
  }
340
- function renderNodes(nodes) {
349
+ function renderNodes(nodes, counters) {
341
350
  return nodes.map(
342
- (node, index) => renderNode({ node, key: index, nextNode: nodes[index + 1] })
351
+ (node, index) => renderNode({ node, key: index, nextNode: nodes[index + 1], counters })
343
352
  );
344
353
  }
345
- function renderSection(section, key = -1) {
354
+ function renderSection(section, counters, key = -1) {
346
355
  var _a;
347
356
  return /* @__PURE__ */ jsxs(BlogSection, { title: (_a = section == null ? void 0 : section.title) != null ? _a : "", children: [
348
- renderNodes(section.nodes),
357
+ renderNodes(section.nodes, counters),
349
358
  section.subsections.map(
350
- (subsection, index) => renderSection(subsection, index)
359
+ (subsection, index) => renderSection(subsection, counters, index)
351
360
  )
352
361
  ] }, key);
353
362
  }
354
363
  var renderMarkdownAst = (ast) => {
364
+ const counters = { mermaid: 0 };
355
365
  const grouped = groupSections(ast.children);
356
366
  return {
357
- sections: grouped.map((section, index) => renderSection(section, index))
367
+ sections: grouped.map((section, index) => renderSection(section, counters, index))
358
368
  };
359
369
  };
360
370
  var MarkdownSections = ({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@san-siva/blogkit-md",
3
- "version": "0.2.9",
3
+ "version": "0.3.2",
4
4
  "description": "Converts markdown files into JSX blog posts for Blogkit",
5
5
  "main": "./dist/index.js",
6
6
  "exports": {
@@ -21,7 +21,7 @@
21
21
  "fix": "eslint . --config eslint.config.ts --fix"
22
22
  },
23
23
  "dependencies": {
24
- "@san-siva/blogkit": "^1.1.23",
24
+ "@san-siva/blogkit": "^1.1.26",
25
25
  "@san-siva/stylekit": "^1.0.12",
26
26
  "next": "^16.0.10",
27
27
  "react": "^19.0.0",
@@ -1,9 +1,15 @@
1
1
  import { describe, expect, it } from 'vitest';
2
+ import remarkGfm from 'remark-gfm';
3
+ import remarkParse from 'remark-parse';
4
+ import { unified } from 'unified';
5
+ import type { Root } from 'mdast';
2
6
 
3
- import { parseMarkdown } from './parseMarkdown';
4
7
  import { groupSections } from './groupSections';
5
8
 
6
- const sections = (md: string) => groupSections(parseMarkdown(md).ast.children);
9
+ const sections = (md: string) => {
10
+ const ast = unified().use(remarkParse).use(remarkGfm).parse(md) as Root;
11
+ return groupSections(ast.children);
12
+ };
7
13
 
8
14
  describe('groupSections', () => {
9
15
  describe('empty input', () => {
@@ -0,0 +1,39 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { parseMarkdown } from './parseMarkdown';
4
+
5
+ describe('parseMarkdown', () => {
6
+ describe('consumeH1Title', () => {
7
+ it('extracts a leading H1 as the title when no frontmatter title is set', () => {
8
+ const { frontmatter, ast } = parseMarkdown('# My Title\n\nSome content');
9
+ expect(frontmatter.title).toBe('My Title');
10
+ });
11
+
12
+ it('removes the H1 from the AST after extracting it as the title', () => {
13
+ const { ast } = parseMarkdown('# My Title\n\nSome content');
14
+ expect(ast.children[0]?.type).not.toBe('heading');
15
+ });
16
+
17
+ it('does not consume the H1 when frontmatter title is already set', () => {
18
+ const { frontmatter, ast } = parseMarkdown('---\ntitle: Frontmatter Title\n---\n\n# H1 Title');
19
+ expect(frontmatter.title).toBe('Frontmatter Title');
20
+ expect(ast.children[0]?.type).toBe('heading');
21
+ });
22
+
23
+ it('does not extract a title when the first heading is not H1', () => {
24
+ const { frontmatter } = parseMarkdown('## Section Title');
25
+ expect(frontmatter.title).toBeUndefined();
26
+ });
27
+
28
+ it('does not extract a title when H1 is not the first node', () => {
29
+ const { frontmatter, ast } = parseMarkdown('Some intro\n\n# Title');
30
+ expect(frontmatter.title).toBeUndefined();
31
+ expect(ast.children).toHaveLength(2);
32
+ });
33
+
34
+ it('handles inline formatting in the H1', () => {
35
+ const { frontmatter } = parseMarkdown('# My **Bold** Title');
36
+ expect(frontmatter.title).toBe('My Bold Title');
37
+ });
38
+ });
39
+ });
@@ -1,10 +1,12 @@
1
- import type { Root, Yaml } from 'mdast';
1
+ import type { Heading, Root, Yaml } from 'mdast';
2
2
  import remarkFrontmatter from 'remark-frontmatter';
3
3
  import remarkGfm from 'remark-gfm';
4
4
  import remarkParse from 'remark-parse';
5
5
  import { unified } from 'unified';
6
6
  import { parse as parseYaml } from 'yaml';
7
7
 
8
+ import { extractText } from './extractText';
9
+
8
10
  export type Frontmatter = {
9
11
  title?: string;
10
12
  description?: string;
@@ -15,6 +17,17 @@ export type ParseResult = {
15
17
  frontmatter: Frontmatter;
16
18
  };
17
19
 
20
+ // When a document has no frontmatter title, treat a leading H1 as the page title
21
+ // and remove it from the AST so it isn't also rendered as a section.
22
+ const consumeH1Title = (ast: Root, frontmatter: Frontmatter): Frontmatter => {
23
+ if (!frontmatter.title && ast.children[0]?.type === 'heading' && (ast.children[0] as Heading).depth === 1) {
24
+ const title = extractText((ast.children[0] as Heading).children);
25
+ ast.children.shift();
26
+ return { ...frontmatter, title };
27
+ }
28
+ return frontmatter;
29
+ };
30
+
18
31
  export const parseMarkdown = (content: string): ParseResult => {
19
32
  const processor = unified().use(remarkParse).use(remarkGfm).use(remarkFrontmatter, ['yaml']);
20
33
  const ast = processor.parse(content) as Root;
@@ -32,5 +45,7 @@ export const parseMarkdown = (content: string): ParseResult => {
32
45
  ast.children.shift();
33
46
  }
34
47
 
48
+ frontmatter = consumeH1Title(ast, frontmatter);
49
+
35
50
  return { ast, frontmatter };
36
51
  };
@@ -3,6 +3,8 @@ import React from 'react';
3
3
  import {
4
4
  BlogSection,
5
5
  Callout,
6
+ CheckList,
7
+ type CheckListItem,
6
8
  CodeBlock,
7
9
  Mermaid,
8
10
  Table,
@@ -15,18 +17,22 @@ import { renderPhrasingContent } from './renderPhrasingContent';
15
17
 
16
18
  import styles from '@san-siva/stylekit/styles/index.module.scss';
17
19
 
20
+ type Counters = { mermaid: number };
21
+
18
22
  function renderNode({
19
23
  node,
20
24
  key,
21
25
  nextNode,
22
26
  inList = false,
23
27
  inCallout = false,
28
+ counters,
24
29
  }: {
25
30
  node: RootContent;
26
31
  key: number;
27
32
  nextNode?: RootContent;
28
33
  inList?: boolean;
29
34
  inCallout?: boolean;
35
+ counters: Counters;
30
36
  }): React.ReactNode {
31
37
  switch (node.type) {
32
38
  case 'paragraph': {
@@ -51,10 +57,11 @@ function renderNode({
51
57
  }
52
58
  case 'code': {
53
59
  if (node.lang === 'mermaid') {
60
+ const mermaidId = counters.mermaid++;
54
61
  return (
55
62
  <Mermaid
56
63
  key={key}
57
- id={`mermaid-${key}`}
64
+ id={`mermaid-${mermaidId}`}
58
65
  code={node.value}
59
66
  hasMarginUp
60
67
  hasMarginDown
@@ -110,32 +117,11 @@ function renderNode({
110
117
  if (firstChild?.type === 'paragraph') {
111
118
  const firstInline = firstChild.children[0];
112
119
  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
- ];
120
+ const githubAlertMatch = /^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]$/i.exec(firstInline.value);
121
+ if (githubAlertMatch) {
122
+ const alertType = githubAlertMatch[1].toUpperCase();
123
+ calloutType = alertType === 'WARNING' ? 'warning' : alertType === 'CAUTION' ? 'error' : 'info';
124
+ strippedChildren = children.slice(1);
139
125
  }
140
126
  }
141
127
  }
@@ -148,12 +134,24 @@ function renderNode({
148
134
  key: index,
149
135
  nextNode: strippedChildren[index + 1],
150
136
  inCallout: true,
137
+ counters,
151
138
  })
152
139
  )}
153
140
  </Callout>
154
141
  );
155
142
  }
156
143
  case 'list': {
144
+ const isTaskList = node.children.every(item => item.checked !== null && item.checked !== undefined);
145
+ if (isTaskList) {
146
+ const items: CheckListItem[] = node.children.map((item, index) => ({
147
+ id: String(index),
148
+ isChecked: item.checked === true,
149
+ children: item.children.map((child, childIndex) =>
150
+ renderNode({ node: child as RootContent, key: childIndex, inList: true, counters })
151
+ ),
152
+ }));
153
+ return <CheckList key={key} items={items} hasMarginUp hasMarginDown />;
154
+ }
157
155
  const Tag = node.ordered ? 'ol' : 'ul';
158
156
  return (
159
157
  <Tag key={key} className={styles['margin-bottom--2']}>
@@ -164,6 +162,7 @@ function renderNode({
164
162
  node: child as RootContent,
165
163
  key: index,
166
164
  inList: true,
165
+ counters,
167
166
  })
168
167
  )}
169
168
  </li>
@@ -177,18 +176,18 @@ function renderNode({
177
176
  }
178
177
  }
179
178
 
180
- function renderNodes(nodes: RootContent[]): React.ReactNode[] {
179
+ function renderNodes(nodes: RootContent[], counters: Counters): React.ReactNode[] {
181
180
  return nodes.map((node, index) =>
182
- renderNode({ node, key: index, nextNode: nodes[index + 1] })
181
+ renderNode({ node, key: index, nextNode: nodes[index + 1], counters })
183
182
  );
184
183
  }
185
184
 
186
- function renderSection(section: Section, key = -1): React.ReactNode {
185
+ function renderSection(section: Section, counters: Counters, key = -1): React.ReactNode {
187
186
  return (
188
187
  <BlogSection key={key} title={section?.title ?? ''}>
189
- {renderNodes(section.nodes)}
188
+ {renderNodes(section.nodes, counters)}
190
189
  {section.subsections.map((subsection, index) =>
191
- renderSection(subsection, index)
190
+ renderSection(subsection, counters, index)
192
191
  )}
193
192
  </BlogSection>
194
193
  );
@@ -199,9 +198,10 @@ export type RenderedMarkdown = {
199
198
  };
200
199
 
201
200
  export const renderMarkdownAst = (ast: Root): RenderedMarkdown => {
201
+ const counters: Counters = { mermaid: 0 };
202
202
  const grouped = groupSections(ast.children);
203
203
  return {
204
- sections: grouped.map((section, index) => renderSection(section, index)),
204
+ sections: grouped.map((section, index) => renderSection(section, counters, index)),
205
205
  };
206
206
  };
207
207