feat: allow converting a page to markdown

This commit is contained in:
Shivam Mishra
2025-06-18 19:25:57 +05:30
parent 7d66691844
commit 62dbf65f99
2 changed files with 518 additions and 0 deletions
+104
View File
@@ -0,0 +1,104 @@
class NotionToMarkdown
# Main entry: pass in the Notion page blocks (array of block hashes)
def convert(blocks)
blocks.map { |block| block_to_markdown(block) }.join("\n\n")
end
private
def block_to_markdown(block, depth = 0)
type = block['type']
return '' unless type
case type
when 'paragraph'
rich_text_to_md(block['paragraph']['rich_text'])
when 'heading_1'
"# #{rich_text_to_md(block['heading_1']['rich_text'])}"
when 'heading_2'
"## #{rich_text_to_md(block['heading_2']['rich_text'])}"
when 'heading_3'
"### #{rich_text_to_md(block['heading_3']['rich_text'])}"
when 'bulleted_list_item'
indent = ' ' * depth
"#{indent}- #{rich_text_to_md(block['bulleted_list_item']['rich_text'])}" +
children_to_md(block, depth + 1)
when 'numbered_list_item'
indent = ' ' * depth
"#{indent}1. #{rich_text_to_md(block['numbered_list_item']['rich_text'])}" +
children_to_md(block, depth + 1)
when 'to_do'
box = block['to_do']['checked'] ? '[x]' : '[ ]'
"- #{box} #{rich_text_to_md(block['to_do']['rich_text'])}"
when 'toggle'
summary = rich_text_to_md(block['toggle']['rich_text'])
details = children_to_md(block, depth + 1)
"<details>\n<summary>#{summary}</summary>\n\n#{details}\n</details>"
when 'quote'
quote = rich_text_to_md(block['quote']['rich_text'])
quote_lines = quote.lines.map { |line| "> #{line}" }.join
quote_lines + children_to_md(block, depth)
when 'code'
lang = block['code']['language'] || ''
code = block['code']['rich_text'].map { |t| t['plain_text'] }.join
"```#{lang}\n#{code}\n```"
when 'callout'
icon = block['callout']['icon']&.dig('emoji') || '💡'
content = rich_text_to_md(block['callout']['rich_text'])
"> [!#{icon}] #{content}" + children_to_md(block, depth + 1)
when 'divider'
'---'
when 'image'
url = block['image']['type'] == 'external' ? block['image']['external']['url'] : block['image']['file']['url']
caption = block['image']['caption']&.map { |c| c['plain_text'] }&.join || 'image'
"![#{caption}](#{url})"
when 'bookmark'
url = block['bookmark']['url']
"[#{url}](#{url})"
when 'child_page'
"## #{block['child_page']['title']}" + children_to_md(block, depth + 1)
when 'table'
table_to_md(block)
else
'' # Add more block types as needed
end
end
def children_to_md(block, depth)
return '' unless block['has_children'] && block['children']
"\n" + block['children'].map { |child| block_to_markdown(child, depth) }.join("\n")
end
def rich_text_to_md(rich_text_array)
return '' unless rich_text_array
rich_text_array.map { |t| annotate_text(t) }.join
end
def annotate_text(text_obj)
text = text_obj['plain_text']
ann = text_obj['annotations'] || {}
text = "**#{text}**" if ann['bold']
text = "*#{text}*" if ann['italic']
text = "~~#{text}~~" if ann['strikethrough']
text = "<u>#{text}</u>" if ann['underline']
text = "`#{text}`" if ann['code']
text = "[#{text}](#{text_obj['href']})" if text_obj['href']
text
end
def table_to_md(block)
rows = block['children'] || []
table = rows.map do |row|
cells = row['table_row']['cells']
cells.map { |cell| rich_text_to_md(cell) }
end
return '' if table.empty?
header = table.first
sep = header.map { '---' }
([header, sep] + table[1..]).map { |row| "| #{row.join(' | ')} |" }.join("\n")
end
end
+414
View File
@@ -0,0 +1,414 @@
require 'rails_helper'
RSpec.describe NotionToMarkdown do
subject(:converter) { described_class.new }
describe '#convert' do
context 'with paragraph blocks' do
let(:blocks) do
[
{
'type' => 'paragraph',
'paragraph' => {
'rich_text' => [
{ 'plain_text' => 'This is a simple paragraph.' }
]
}
}
]
end
it 'converts paragraph to markdown' do
result = converter.convert(blocks)
expect(result).to eq('This is a simple paragraph.')
end
end
context 'with heading blocks' do
let(:blocks) do
[
{
'type' => 'heading_1',
'heading_1' => {
'rich_text' => [{ 'plain_text' => 'Main Title' }]
}
},
{
'type' => 'heading_2',
'heading_2' => {
'rich_text' => [{ 'plain_text' => 'Subtitle' }]
}
},
{
'type' => 'heading_3',
'heading_3' => {
'rich_text' => [{ 'plain_text' => 'Sub-subtitle' }]
}
}
]
end
it 'converts headings to markdown' do
result = converter.convert(blocks)
expected = "# Main Title\n\n## Subtitle\n\n### Sub-subtitle"
expect(result).to eq(expected)
end
end
context 'with list blocks' do
let(:blocks) do
[
{
'type' => 'bulleted_list_item',
'bulleted_list_item' => {
'rich_text' => [{ 'plain_text' => 'First bullet point' }]
}
},
{
'type' => 'numbered_list_item',
'numbered_list_item' => {
'rich_text' => [{ 'plain_text' => 'First numbered item' }]
}
}
]
end
it 'converts lists to markdown' do
result = converter.convert(blocks)
expected = "- First bullet point\n\n1. First numbered item"
expect(result).to eq(expected)
end
end
context 'with to-do blocks' do
let(:blocks) do
[
{
'type' => 'to_do',
'to_do' => {
'checked' => false,
'rich_text' => [{ 'plain_text' => 'Incomplete task' }]
}
},
{
'type' => 'to_do',
'to_do' => {
'checked' => true,
'rich_text' => [{ 'plain_text' => 'Completed task' }]
}
}
]
end
it 'converts to-dos to markdown' do
result = converter.convert(blocks)
expected = "- [ ] Incomplete task\n\n- [x] Completed task"
expect(result).to eq(expected)
end
end
context 'with rich text formatting' do
let(:blocks) do
[
{
'type' => 'paragraph',
'paragraph' => {
'rich_text' => [
{
'plain_text' => 'This is bold',
'annotations' => { 'bold' => true }
},
{ 'plain_text' => ' and ' },
{
'plain_text' => 'this is italic',
'annotations' => { 'italic' => true }
},
{ 'plain_text' => ' and ' },
{
'plain_text' => 'this is code',
'annotations' => { 'code' => true }
}
]
}
}
]
end
it 'applies rich text formatting' do
result = converter.convert(blocks)
expected = '**This is bold** and *this is italic* and `this is code`'
expect(result).to eq(expected)
end
end
context 'with links' do
let(:blocks) do
[
{
'type' => 'paragraph',
'paragraph' => {
'rich_text' => [
{
'plain_text' => 'Visit our website',
'href' => 'https://example.com'
}
]
}
}
]
end
it 'converts links to markdown' do
result = converter.convert(blocks)
expected = '[Visit our website](https://example.com)'
expect(result).to eq(expected)
end
end
context 'with code blocks' do
let(:blocks) do
[
{
'type' => 'code',
'code' => {
'language' => 'javascript',
'rich_text' => [
{ 'plain_text' => "console.log('Hello, world!');" }
]
}
}
]
end
it 'converts code blocks to markdown' do
result = converter.convert(blocks)
expected = "```javascript\nconsole.log('Hello, world!');\n```"
expect(result).to eq(expected)
end
end
context 'with quote blocks' do
let(:blocks) do
[
{
'type' => 'quote',
'quote' => {
'rich_text' => [
{ 'plain_text' => 'This is a quote' }
]
}
}
]
end
it 'converts quotes to markdown' do
result = converter.convert(blocks)
expected = '> This is a quote'
expect(result).to eq(expected)
end
end
context 'with callout blocks' do
let(:blocks) do
[
{
'type' => 'callout',
'callout' => {
'icon' => { 'emoji' => '⚠️' },
'rich_text' => [
{ 'plain_text' => 'This is a warning' }
]
}
}
]
end
it 'converts callouts to markdown' do
result = converter.convert(blocks)
expected = '> [!⚠️] This is a warning'
expect(result).to eq(expected)
end
end
context 'with divider blocks' do
let(:blocks) do
[
{
'type' => 'divider',
'divider' => {}
}
]
end
it 'converts dividers to markdown' do
result = converter.convert(blocks)
expected = '---'
expect(result).to eq(expected)
end
end
context 'with image blocks' do
let(:blocks) do
[
{
'type' => 'image',
'image' => {
'type' => 'external',
'external' => {
'url' => 'https://example.com/image.jpg'
},
'caption' => [
{ 'plain_text' => 'Example image' }
]
}
}
]
end
it 'converts images to markdown' do
result = converter.convert(blocks)
expected = '![Example image](https://example.com/image.jpg)'
expect(result).to eq(expected)
end
end
context 'with bookmark blocks' do
let(:blocks) do
[
{
'type' => 'bookmark',
'bookmark' => {
'url' => 'https://example.com'
}
}
]
end
it 'converts bookmarks to markdown' do
result = converter.convert(blocks)
expected = '[https://example.com](https://example.com)'
expect(result).to eq(expected)
end
end
context 'with table blocks' do
let(:blocks) do
[
{
'type' => 'table',
'table' => {},
'children' => [
{
'type' => 'table_row',
'table_row' => {
'cells' => [
[{ 'plain_text' => 'Name' }],
[{ 'plain_text' => 'Age' }]
]
}
},
{
'type' => 'table_row',
'table_row' => {
'cells' => [
[{ 'plain_text' => 'John' }],
[{ 'plain_text' => '30' }]
]
}
}
]
}
]
end
it 'converts tables to markdown' do
result = converter.convert(blocks)
expected = "| Name | Age |\n| --- | --- |\n| John | 30 |"
expect(result).to eq(expected)
end
end
context 'with nested list items' do
let(:blocks) do
[
{
'type' => 'bulleted_list_item',
'bulleted_list_item' => {
'rich_text' => [{ 'plain_text' => 'Parent item' }]
},
'has_children' => true,
'children' => [
{
'type' => 'bulleted_list_item',
'bulleted_list_item' => {
'rich_text' => [{ 'plain_text' => 'Child item' }]
}
}
]
}
]
end
it 'handles nested lists with proper indentation' do
result = converter.convert(blocks)
expected = "- Parent item\n - Child item"
expect(result).to eq(expected)
end
end
context 'with toggle blocks' do
let(:blocks) do
[
{
'type' => 'toggle',
'toggle' => {
'rich_text' => [{ 'plain_text' => 'Click to expand' }]
},
'has_children' => true,
'children' => [
{
'type' => 'paragraph',
'paragraph' => {
'rich_text' => [{ 'plain_text' => 'Hidden content' }]
}
}
]
}
]
end
it 'converts toggles to HTML details' do
result = converter.convert(blocks)
expected = "<details>\n<summary>Click to expand</summary>\n\n\nHidden content\n</details>"
expect(result).to eq(expected)
end
end
context 'with unknown block types' do
let(:blocks) do
[
{
'type' => 'unknown_block_type',
'unknown_block_type' => {
'content' => 'Some content'
}
}
]
end
it 'ignores unknown block types' do
result = converter.convert(blocks)
expect(result).to eq('')
end
end
context 'with empty blocks array' do
let(:blocks) { [] }
it 'returns empty string' do
result = converter.convert(blocks)
expect(result).to eq('')
end
end
end
end