notion_to_md 2.5.1 → 3.0.0.beta2

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.
@@ -3,135 +3,109 @@
3
3
  class NotionToMd
4
4
  # === NotionToMd::Page
5
5
  #
6
- # This class is responsible for representing a Notion page.
6
+ # Represents a Notion page and allows conversion to Markdown.
7
+ #
8
+ # A `Page` object encapsulates the page metadata, its content blocks,
9
+ # and optionally the frontmatter section.
10
+ #
11
+ # @example Convert a Notion page to Markdown
12
+ # notion_client = Notion::Client.new(token: ENV["NOTION_TOKEN"])
13
+ # page = NotionToMd::Page.call(id: "xxxx-xxxx", notion_client: notion_client, frontmatter: true)
14
+ # File.write("page.md", page.to_s)
15
+ #
7
16
  class Page
8
- include Helpers::YamlSanitizer
9
-
10
- attr_reader :page, :blocks
11
-
12
- def initialize(page:, blocks:)
13
- @page = page
14
- @blocks = blocks
15
- end
16
-
17
- def title
18
- title_list = page.dig(:properties, :Name, :title) || page.dig(:properties, :title, :title)
19
- title_list.inject('') do |acc, slug|
20
- acc + slug[:plain_text]
17
+ include Support::MetadataProperties
18
+ include Support::Frontmatter
19
+
20
+ class << self
21
+ # Build a new {Page} from the Notion API.
22
+ #
23
+ # @param id [String] The Notion page ID.
24
+ # @param notion_client [Notion::Client] The Notion API client.
25
+ # @param frontmatter [Boolean] Whether to include frontmatter in the output.
26
+ #
27
+ # @return [NotionToMd::Page] a new page instance.
28
+ #
29
+ # @see .build
30
+ def call(id:, notion_client:, frontmatter: false)
31
+ new(id: id, notion_client: notion_client, frontmatter: frontmatter).call
21
32
  end
22
- end
23
33
 
24
- def cover
25
- PageProperty.external(page[:cover]) || PageProperty.file(page[:cover])
34
+ # @!method build(...)
35
+ # Alias of {.call}.
36
+ alias build call
26
37
  end
27
38
 
28
- def icon
29
- PageProperty.emoji(page[:icon]) || PageProperty.external(page[:icon]) || PageProperty.file(page[:icon])
30
- end
39
+ # @return [String] The Notion id page.
40
+ attr_reader :id
31
41
 
32
- def id
33
- page[:id]
34
- end
35
-
36
- def created_time
37
- page['created_time']
38
- end
42
+ # @param jNotion::Client] The Notion API client.
43
+ attr_reader :notion_client
39
44
 
40
- def created_by_object
41
- page.dig(:created_by, :object)
42
- end
45
+ # @param [Hash] The page configuration options.
46
+ attr_reader :config
43
47
 
44
- def created_by_id
45
- page.dig(:created_by, :id)
46
- end
48
+ # @return [Object] The metadata associated with the page.
49
+ attr_reader :metadata
47
50
 
48
- def last_edited_time
49
- page['last_edited_time']
50
- end
51
+ # @return [Array<#to_md>] The list of child blocks belonging to the page.
52
+ attr_reader :children
51
53
 
52
- def last_edited_by_object
53
- page.dig(:last_edited_by, :object)
54
- end
54
+ # @!method blocks
55
+ # Alias for {#children}.
56
+ alias blocks children
55
57
 
56
- def last_edited_by_id
57
- page.dig(:last_edited_by, :id)
58
+ # Initialize a new Page.
59
+ #
60
+ # @param id [String] The Notion page ID.
61
+ # @param notion_client [Notion::Client] The Notion API client.
62
+ # @param frontmatter [Boolean] Whether to include frontmatter in the Markdown output.
63
+ def initialize(id:, notion_client:, frontmatter: false)
64
+ @id = id
65
+ @notion_client = notion_client
66
+ @config = { frontmatter: frontmatter }
58
67
  end
59
68
 
60
- def url
61
- page[:url]
69
+ # Fetch page data and child blocks from the Notion API.
70
+ #
71
+ # This method populates the page's metadata and children by making API calls
72
+ # to retrieve the page content and its nested blocks.
73
+ #
74
+ # @return [NotionToMd::Page] returns self to allow method chaining.
75
+ def call
76
+ @metadata = notion_client.page(page_id: id)
77
+ @children = Builder.call(block_id: id, notion_client: notion_client)
78
+ self
62
79
  end
63
80
 
64
- def archived
65
- page[:archived]
66
- end
81
+ alias build call
67
82
 
83
+ # Render the body of the page (Markdown representation of its blocks).
84
+ #
85
+ # @return [String] The Markdown content of the page.
68
86
  def body
69
87
  @body ||= blocks.map(&:to_md).compact.join
70
88
  end
71
89
 
72
- def frontmatter
73
- @frontmatter ||= <<~CONTENT
74
- ---
75
- #{props.to_a.map do |k, v|
76
- "#{k}: #{v}"
77
- end.join("\n")}
78
- ---
79
- CONTENT
90
+ # Whether the page includes frontmatter.
91
+ #
92
+ # @return [Boolean]
93
+ def frontmatter?
94
+ config[:frontmatter]
80
95
  end
81
96
 
82
- def props
83
- @props ||= custom_props.deep_merge(default_props)
97
+ # Render the page as a Markdown string, including optional frontmatter.
98
+ #
99
+ # @return [String] The Markdown document.
100
+ def to_s
101
+ <<~MD
102
+ #{frontmatter if frontmatter?}
103
+ #{body}
104
+ MD
84
105
  end
85
106
 
86
- def custom_props
87
- @custom_props ||= filtered_custom_properties
88
- end
89
-
90
- # rubocop:disable Metrics/MethodLength
91
- def default_props
92
- @default_props ||= {
93
- 'id' => id,
94
- 'title' => escape_frontmatter_value(title),
95
- 'created_time' => created_time,
96
- 'cover' => cover,
97
- 'icon' => icon,
98
- 'last_edited_time' => last_edited_time,
99
- 'archived' => archived,
100
- 'created_by_object' => created_by_object,
101
- 'created_by_id' => created_by_id,
102
- 'last_edited_by_object' => last_edited_by_object,
103
- 'last_edited_by_id' => last_edited_by_id
104
- }
105
- end
106
- # rubocop:enable Metrics/MethodLength
107
-
108
- # This class is kept for retro compatibility reasons.
109
- # Use instead the PageProperty class.
110
- class CustomProperty < PageProperty
111
- end
112
-
113
- private
114
-
115
- def filtered_custom_properties
116
- build_custom_properties.reject { |_k, v| v.presence.nil? }
117
- end
118
-
119
- def build_custom_properties
120
- page.properties.each_with_object({}) do |(name, value), memo|
121
- type = value.type
122
- next unless valid_custom_property_type?(type)
123
-
124
- key = name.parameterize.underscore
125
- memo[key] = build_custom_property(type, value)
126
- end
127
- end
128
-
129
- def valid_custom_property_type?(type)
130
- CustomProperty.respond_to?(type.to_sym)
131
- end
132
-
133
- def build_custom_property(type, value)
134
- CustomProperty.send(type, value)
135
- end
107
+ # @!method to_md
108
+ # Alias for {#to_s}.
109
+ alias to_md to_s
136
110
  end
137
111
  end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ class NotionToMd
4
+ module Support
5
+ # === NotionToMd::Support::Frontmatter
6
+ #
7
+ # Mixin module responsible for generating YAML frontmatter for
8
+ # Notion pages and databases. Combines default metadata (id, title,
9
+ # timestamps, cover, icon, etc.) with supported custom properties.
10
+ #
11
+ # @example Use in a page class
12
+ # class Page
13
+ # include NotionToMd::Support::MetadataProperties
14
+ # include NotionToMd::Support::Frontmatter
15
+ # attr_reader :metadata
16
+ # end
17
+ #
18
+ # page = Page.new(metadata: notion_page_hash)
19
+ # puts page.frontmatter
20
+ # # =>
21
+ # # ---
22
+ # # id: 1234
23
+ # # title: "Hello World"
24
+ # # created_time: 2025-01-01 00:00:00 +0000
25
+ # # ...
26
+ # # ---
27
+ #
28
+ # @see NotionToMd::Support::YamlSanitizer
29
+ # @see NotionToMd::MetadataType
30
+ module Frontmatter
31
+ include YamlSanitizer
32
+
33
+ # Generate the YAML frontmatter string by merging default and custom properties.
34
+ #
35
+ # @return [String] YAML frontmatter block, surrounded by `---` markers.
36
+ def frontmatter
37
+ @frontmatter ||= <<~CONTENT
38
+ ---
39
+ #{frontmatter_properties.to_a.map do |k, v|
40
+ "#{k}: #{v}"
41
+ end.join("\n")}
42
+ ---
43
+ CONTENT
44
+ end
45
+
46
+ # Merge custom and default properties into the final set of frontmatter properties.
47
+ #
48
+ # @return [Hash{String => Object}]
49
+ def frontmatter_properties
50
+ @frontmatter_properties ||= frontmatter_custom_properties.deep_merge(frontmatter_default_properties)
51
+ end
52
+
53
+ private
54
+
55
+ # Retrieve sanitized custom properties.
56
+ #
57
+ # @return [Hash{String => Object}]
58
+ def frontmatter_custom_properties
59
+ @frontmatter_custom_properties ||= compact_frontmatter_custom_properties
60
+ end
61
+
62
+ # Remove nil/blank values from custom properties.
63
+ #
64
+ # @return [Hash{String => Object}]
65
+ def compact_frontmatter_custom_properties
66
+ build_frontmatter_custom_properties.reject { |_k, v| v.presence.nil? }
67
+ end
68
+
69
+ # Build supported custom properties based on their type.
70
+ #
71
+ # @return [Hash{String => Object}]
72
+ def build_frontmatter_custom_properties
73
+ properties.each_with_object({}) do |(name, value), memo|
74
+ type = value.type
75
+ next unless valid_custom_property_type?(type)
76
+
77
+ key = name.parameterize.underscore
78
+ memo[key] = build_custom_property(type, value)
79
+ end
80
+ end
81
+
82
+ # Determine whether a custom property type is supported.
83
+ #
84
+ # @param type [String, Symbol]
85
+ # @return [Boolean]
86
+ def valid_custom_property_type?(type)
87
+ MetadataType.respond_to?(type.to_sym)
88
+ end
89
+
90
+ # Convert a Notion property into a YAML-safe value.
91
+ #
92
+ # @param type [String, Symbol] The property type.
93
+ # @param value [Object] The raw Notion property value.
94
+ # @return [Object] The normalized value suitable for YAML.
95
+ def build_custom_property(type, value)
96
+ MetadataType.send(type, value)
97
+ end
98
+
99
+ # Default frontmatter properties derived from metadata.
100
+ #
101
+ # @return [Hash{String => Object}]
102
+ def frontmatter_default_properties
103
+ @frontmatter_default_properties ||= {
104
+ 'id' => metadata['id'],
105
+ 'title' => escape_frontmatter_value(title),
106
+ 'created_time' => created_time,
107
+ 'cover' => cover,
108
+ 'icon' => icon,
109
+ 'last_edited_time' => last_edited_time,
110
+ 'archived' => archived,
111
+ 'created_by_object' => created_by_object,
112
+ 'created_by_id' => created_by_id,
113
+ 'last_edited_by_object' => last_edited_by_object,
114
+ 'last_edited_by_id' => last_edited_by_id
115
+ }
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ class NotionToMd
4
+ module Support
5
+ # === NotionToMd::Support::MetadataProperties
6
+ #
7
+ # Mixin module providing convenience accessors for Notion page or
8
+ # database metadata. It extracts common properties (id, title, timestamps,
9
+ # cover, icon, etc.) from the `metadata` hash returned by the Notion API.
10
+ #
11
+ # When included, this module delegates `#properties` to the `metadata`
12
+ # attribute of the including class.
13
+ #
14
+ # @example Include in a model
15
+ # class Page
16
+ # include NotionToMd::Support::MetadataProperties
17
+ # attr_reader :metadata
18
+ # end
19
+ #
20
+ # page = Page.new(metadata: notion_page_hash)
21
+ # page.title # => "My Notion Page"
22
+ #
23
+ # @see NotionToMd::MetadataType
24
+ module MetadataProperties
25
+ def self.included(base)
26
+ base.extend Forwardable
27
+ base.def_delegators :metadata, :properties
28
+ end
29
+
30
+ # Extract the page or database title.
31
+ #
32
+ # @return [String] Plain text concatenated from `title` property.
33
+ def title
34
+ title_list =
35
+ metadata[:title] ||
36
+ metadata.dig(:properties, :Name, :title) ||
37
+ metadata.dig(:properties, :title, :title)
38
+
39
+ title_list.inject('') do |acc, slug|
40
+ acc + slug[:plain_text]
41
+ end
42
+ end
43
+
44
+ # @return [Time] Creation timestamp.
45
+ def created_time
46
+ metadata[:created_time]
47
+ end
48
+
49
+ # @return [String, nil] Object type of creator.
50
+ def created_by_object
51
+ metadata.dig(:created_by, :object)
52
+ end
53
+
54
+ # @return [String, nil] ID of creator.
55
+ def created_by_id
56
+ metadata.dig(:created_by, :id)
57
+ end
58
+
59
+ # @return [Time] Last edited timestamp.
60
+ def last_edited_time
61
+ metadata[:last_edited_time]
62
+ end
63
+
64
+ # @return [String, nil] Object type of last editor.
65
+ def last_edited_by_object
66
+ metadata.dig(:last_edited_by, :object)
67
+ end
68
+
69
+ # @return [String, nil] ID of last editor.
70
+ def last_edited_by_id
71
+ metadata.dig(:last_edited_by, :id)
72
+ end
73
+
74
+ # @return [String] Public URL of the record.
75
+ def url
76
+ metadata[:url]
77
+ end
78
+
79
+ # @return [Boolean] Whether the record is archived.
80
+ def archived
81
+ metadata[:archived]
82
+ end
83
+
84
+ # Extract the cover image URL, from either external or file source.
85
+ #
86
+ # @return [String, nil] Cover URL or nil if not present.
87
+ def cover
88
+ MetadataType.external(metadata[:cover]) ||
89
+ MetadataType.file(metadata[:cover])
90
+ end
91
+
92
+ # Extract the icon (emoji, external, or file).
93
+ #
94
+ # @return [String, nil] Icon value or nil if not present.
95
+ def icon
96
+ MetadataType.emoji(metadata[:icon]) ||
97
+ MetadataType.external(metadata[:icon]) ||
98
+ MetadataType.file(metadata[:icon])
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ class NotionToMd
4
+ module Support
5
+ ##
6
+ # The Pagination module provides a helper for iterating through paginated
7
+ # Notion API responses.
8
+ #
9
+ # Many Notion API endpoints (e.g., `block_children`, `database_query`) return
10
+ # a limited set of results and include `has_more` and `next_cursor` fields.
11
+ # This module encapsulates the common logic to repeatedly fetch all pages
12
+ # until no more results are available.
13
+ #
14
+ # @example Paginate through all blocks of a page
15
+ # include NotionToMd::Support::Pagination
16
+ #
17
+ # all_blocks = paginate do |cursor|
18
+ # notion_client.block_children(
19
+ # block_id: "xxxx-xxxx",
20
+ # start_cursor: cursor
21
+ # )
22
+ # end
23
+ #
24
+ # all_blocks.each { |block| puts block.type }
25
+ #
26
+ module Pagination
27
+ ##
28
+ # Iterates through all pages of a paginated Notion API response.
29
+ #
30
+ # The given block is called with the current cursor (or `nil` for the
31
+ # first call). It must return a response object that responds to
32
+ # `results`, `has_more`, and `next_cursor`.
33
+ #
34
+ # @yieldparam [String, nil] cursor the current cursor to start fetching from
35
+ # @yieldreturn [Object] a response object containing `results`, `has_more`, and `next_cursor`
36
+ # @return [Array] a flat array of all accumulated results from each page
37
+ def paginate
38
+ results = []
39
+ cursor = nil
40
+
41
+ loop do
42
+ resp = yield(cursor)
43
+
44
+ results.concat(resp.results)
45
+
46
+ break unless resp.has_more && resp.next_cursor
47
+
48
+ cursor = resp.next_cursor
49
+ end
50
+
51
+ results
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ class NotionToMd
4
+ module Support
5
+ # === NotionToMd::Support::YamlSanitizer
6
+ #
7
+ # Provides helpers to sanitize values before inserting them into
8
+ # YAML frontmatter. This prevents syntax errors when values contain
9
+ # characters that YAML treats specially (e.g. colons, dashes).
10
+ #
11
+ # @example Escape a simple value
12
+ # include NotionToMd::Support::YamlSanitizer
13
+ #
14
+ # escape_frontmatter_value("Hello World")
15
+ # # => "Hello World"
16
+ #
17
+ # @example Escape a value containing a colon
18
+ # escape_frontmatter_value("Title: Subtitle")
19
+ # # => "\"Title: Subtitle\""
20
+ #
21
+ # @example Escape a value containing dash + space
22
+ # escape_frontmatter_value("- item")
23
+ # # => "\"- item\""
24
+ #
25
+ module YamlSanitizer
26
+ # Escape a frontmatter value if it contains a colon (`:`) followed by
27
+ # a space, or a dash followed by a space (`- `). Wraps the string in
28
+ # double quotes and escapes any embedded quotes.
29
+ #
30
+ # @param value [String] The raw value to escape.
31
+ # @return [String] A safe string suitable for inclusion in YAML frontmatter.
32
+ def escape_frontmatter_value(value)
33
+ if value.match?(/: |-\s/)
34
+ # Escape embedded double quotes to keep valid YAML
35
+ "\"#{value.gsub('"', '\"')}\""
36
+ else
37
+ value
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -1,12 +1,39 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class NotionToMd
4
+ # === NotionToMd::Text
5
+ #
6
+ # Utility class to render inline Notion text elements into Markdown.
7
+ # Used by {NotionToMd::Blocks::Renderer} when processing `rich_text` arrays.
8
+ #
9
+ # Supported types:
10
+ # * `text` → plain text
11
+ # * `equation` → LaTeX inline math, wrapped as `$`…`$`
12
+ #
13
+ # @example Render plain text
14
+ # NotionToMd::Text.text({ plain_text: "Hello" })
15
+ # # => "Hello"
16
+ #
17
+ # @example Render an inline equation
18
+ # NotionToMd::Text.equation({ plain_text: "E=mc^2" })
19
+ # # => "$`E=mc^2`$"
20
+ #
21
+ # @see NotionToMd::Blocks::Renderer
22
+ # @see NotionToMd::TextAnnotation
4
23
  class Text
5
24
  class << self
25
+ # Render a plain text element.
26
+ #
27
+ # @param text [Hash] A Notion text object with key `:plain_text`.
28
+ # @return [String] The raw text.
6
29
  def text(text)
7
30
  text[:plain_text]
8
31
  end
9
32
 
33
+ # Render an inline equation element.
34
+ #
35
+ # @param text [Hash] A Notion text object with key `:plain_text`.
36
+ # @return [String] Inline math expression wrapped with `$...$`.
10
37
  def equation(text)
11
38
  "$`#{text[:plain_text]}`$"
12
39
  end
@@ -1,37 +1,69 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class NotionToMd
4
- ##
5
- # Append the text type:
6
- # * italic: boolean,
7
- # * bold: boolean,
8
- # * striketrough: boolean,
9
- # * underline: boolean,
10
- # * code: boolean,
11
- # * color: string NOT_SUPPORTED
12
-
4
+ # === NotionToMd::TextAnnotation
5
+ #
6
+ # Utility class to wrap text with Markdown (or HTML) syntax
7
+ # corresponding to Notion's text annotations.
8
+ #
9
+ # Supported annotations:
10
+ # * `italic` → `*text*`
11
+ # * `bold` `**text**`
12
+ # * `strikethrough` → `~~text~~`
13
+ # * `underline` → `<u>text</u>` (HTML, since Markdown does not support underline)
14
+ # * `code` → `` `text` ``
15
+ # * `color` → (not supported, returns text as-is)
16
+ #
17
+ # @example Apply bold and italic
18
+ # NotionToMd::TextAnnotation.bold("Hello") # => "**Hello**"
19
+ # NotionToMd::TextAnnotation.italic("World") # => "*World*"
20
+ #
21
+ # @example Apply underline
22
+ # NotionToMd::TextAnnotation.underline("Note") # => "<u>Note</u>"
23
+ #
24
+ # @see NotionToMd::Blocks::Renderer
13
25
  class TextAnnotation
14
26
  class << self
27
+ # Apply italic annotation.
28
+ # @param text [String]
29
+ # @return [String]
15
30
  def italic(text)
16
31
  "*#{text}*"
17
32
  end
18
33
 
34
+ # Apply bold annotation.
35
+ # @param text [String]
36
+ # @return [String]
19
37
  def bold(text)
20
38
  "**#{text}**"
21
39
  end
22
40
 
41
+ # Apply strikethrough annotation.
42
+ # @param text [String]
43
+ # @return [String]
23
44
  def strikethrough(text)
24
45
  "~~#{text}~~"
25
46
  end
26
47
 
48
+ # Apply underline annotation (HTML).
49
+ # @param text [String]
50
+ # @return [String]
27
51
  def underline(text)
28
52
  "<u>#{text}</u>"
29
53
  end
30
54
 
55
+ # Apply inline code annotation.
56
+ # @param text [String]
57
+ # @return [String]
31
58
  def code(text)
32
59
  "`#{text}`"
33
60
  end
34
61
 
62
+ # Color annotation is not supported in Markdown.
63
+ # Currently returns text unchanged.
64
+ #
65
+ # @param text [String]
66
+ # @return [String]
35
67
  def color(text)
36
68
  text
37
69
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class NotionToMd
4
- VERSION = '2.5.1'
4
+ VERSION = '3.0.0.beta2'
5
5
  end