notion_to_md 2.5.1 → 3.0.0.beta1

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,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
+ private
47
+
48
+ # Merge custom and default properties into the final set of frontmatter properties.
49
+ #
50
+ # @return [Hash{String => Object}]
51
+ def frontmatter_properties
52
+ @frontmatter_properties ||= frontmatter_custom_properties.deep_merge(frontmatter_default_properties)
53
+ end
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' => 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,107 @@
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
+ # @return [String] The Notion record ID.
31
+ def id
32
+ metadata[:id]
33
+ end
34
+
35
+ # Extract the page or database title.
36
+ #
37
+ # @return [String] Plain text concatenated from `title` property.
38
+ def title
39
+ title_list =
40
+ metadata[:title] ||
41
+ metadata.dig(:properties, :Name, :title) ||
42
+ metadata.dig(:properties, :title, :title)
43
+
44
+ title_list.inject('') do |acc, slug|
45
+ acc + slug[:plain_text]
46
+ end
47
+ end
48
+
49
+ # @return [Time] Creation timestamp.
50
+ def created_time
51
+ metadata[:created_time]
52
+ end
53
+
54
+ # @return [String, nil] Object type of creator.
55
+ def created_by_object
56
+ metadata.dig(:created_by, :object)
57
+ end
58
+
59
+ # @return [String, nil] ID of creator.
60
+ def created_by_id
61
+ metadata.dig(:created_by, :id)
62
+ end
63
+
64
+ # @return [Time] Last edited timestamp.
65
+ def last_edited_time
66
+ metadata[:last_edited_time]
67
+ end
68
+
69
+ # @return [String, nil] Object type of last editor.
70
+ def last_edited_by_object
71
+ metadata.dig(:last_edited_by, :object)
72
+ end
73
+
74
+ # @return [String, nil] ID of last editor.
75
+ def last_edited_by_id
76
+ metadata.dig(:last_edited_by, :id)
77
+ end
78
+
79
+ # @return [String] Public URL of the record.
80
+ def url
81
+ metadata[:url]
82
+ end
83
+
84
+ # @return [Boolean] Whether the record is archived.
85
+ def archived
86
+ metadata[:archived]
87
+ end
88
+
89
+ # Extract the cover image URL, from either external or file source.
90
+ #
91
+ # @return [String, nil] Cover URL or nil if not present.
92
+ def cover
93
+ MetadataType.external(metadata[:cover]) ||
94
+ MetadataType.file(metadata[:cover])
95
+ end
96
+
97
+ # Extract the icon (emoji, external, or file).
98
+ #
99
+ # @return [String, nil] Icon value or nil if not present.
100
+ def icon
101
+ MetadataType.emoji(metadata[:icon]) ||
102
+ MetadataType.external(metadata[:icon]) ||
103
+ MetadataType.file(metadata[:icon])
104
+ end
105
+ end
106
+ end
107
+ 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.beta1'
5
5
  end
data/lib/notion_to_md.rb CHANGED
@@ -4,49 +4,76 @@ require 'notion'
4
4
  require 'logger'
5
5
  require 'active_support/inflector'
6
6
  require 'active_support/core_ext/object/blank'
7
- require 'callee'
8
- require_relative './notion_to_md/helpers'
9
- require_relative './notion_to_md/version'
10
- require_relative './notion_to_md/converter'
11
- require_relative './notion_to_md/logger'
12
- require_relative './notion_to_md/page_property'
13
- require_relative './notion_to_md/page'
14
- require_relative './notion_to_md/blocks'
15
- require_relative './notion_to_md/text'
16
- require_relative './notion_to_md/text_annotation'
17
-
18
- # The NotionToMd class allows to transform notion pages to markdown documents.
19
- class NotionToMd
20
- include Callee
21
-
22
- attr_reader :page_id, :token, :frontmatter
7
+ require 'zeitwerk'
23
8
 
24
- def initialize(page_id:, token: nil, frontmatter: false)
25
- @page_id = page_id
26
- @token = token || ENV['NOTION_TOKEN']
27
- @frontmatter = frontmatter
28
- end
9
+ # Load the NotionToMd classes using Zeitwerk
10
+ loader = Zeitwerk::Loader.for_gem
11
+ loader.setup
29
12
 
30
- # === Parameters
31
- # page_id::
32
- # A string representing the notion page id.
33
- # token::
34
- # The notion API secret token. The token can replaced by the environment variable NOTION_TOKEN.
35
- # frontmatter::
36
- # A boolean indicating whether to include the page properties as frontmatter.
37
- #
38
- # === Returns
39
- # The string that represent the markdown document.
13
+ ##
14
+ # The {NotionToMd} class is the main entry point for converting
15
+ # Notion pages and databases into Markdown documents.
16
+ #
17
+ # It provides a single class method {.call} (aliased as {.convert})
18
+ # that accepts a Notion resource type (`:page` or `:database`), an ID,
19
+ # and options to control conversion.
20
+ #
21
+ # @example Convert a single Notion page to Markdown
22
+ # markdown = NotionToMd.call(:page, id: "xxxx-xxxx", token: "secret_token")
23
+ #
24
+ # @example Convert a Notion database to Markdown and yield the result
25
+ # NotionToMd.convert(:database, id: "xxxx-xxxx").each_with_index do |md, index|
26
+ # File.write("output_#{index}.md", md)
27
+ # end
28
+ #
29
+ class NotionToMd
30
+ ##
31
+ # Supported resource types for conversion.
40
32
  #
41
- def self.convert(page_id:, token: nil, frontmatter: false)
42
- Converter.new(page_id: page_id, token: token).convert(frontmatter: frontmatter)
43
- end
33
+ # @return [Hash{Symbol => Symbol}] mapping of friendly keys to types
34
+ TYPES = { database: :database, page: :page }.freeze
35
+
36
+ class << self
37
+ ##
38
+ # Convert a Notion resource (page or database) to Markdown.
39
+ #
40
+ # @param type [Symbol] the type of Notion resource (`:page` or `:database`).
41
+ # @param id [String] the Notion page or database ID.
42
+ # @param token [String, nil] the Notion API token.
43
+ # If omitted, defaults to `ENV['NOTION_TOKEN']`.
44
+ # @param frontmatter [Boolean] whether to include YAML frontmatter
45
+ # in the generated Markdown.
46
+ #
47
+ # @yield [md] optional block to handle the generated Markdown.
48
+ # @yieldparam md [String] the Markdown output.
49
+ #
50
+ # @return [String] the Markdown representation of the Notion resource.
51
+ #
52
+ # @raise [RuntimeError] if the given +type+ is not supported.
53
+ #
54
+ def call(type, id:, token: nil, frontmatter: false)
55
+ raise "#{type} is not supported. Use :database or :page" unless TYPES.values.include?(type)
56
+
57
+ notion_client = Notion::Client.new(token: token || ENV.fetch('NOTION_TOKEN', nil))
58
+ md = case type
59
+ when TYPES[:database]
60
+ Database.call(id: id, notion_client: notion_client, frontmatter: frontmatter).to_md
61
+ when TYPES[:page]
62
+ Page.call(id: id, notion_client: notion_client, frontmatter: frontmatter).to_md
63
+ end
44
64
 
45
- def call
46
- md = self.class.convert(page_id: page_id, token: token, frontmatter: frontmatter)
65
+ yield md if block_given?
47
66
 
48
- yield md if block_given?
67
+ md
68
+ end
49
69
 
50
- md
70
+ ##
71
+ # Alias for {.call}.
72
+ #
73
+ # @see .call
74
+ #
75
+ def convert(type, id:, token: nil, frontmatter: false, &block)
76
+ call(type, id: id, token: token, frontmatter: frontmatter, &block)
77
+ end
51
78
  end
52
79
  end