├── .ruby-version ├── spec ├── fixtures │ ├── my_site_2 │ │ ├── _config.yml │ │ ├── page-1.md │ │ ├── page-2.md │ │ ├── _posts │ │ │ ├── 2022-01-01-my-post.md │ │ │ └── 2022-01-23-page-1.md │ │ └── _layouts │ │ │ └── post.html │ ├── golden │ │ ├── 2022-01-01-my-post.html │ │ ├── lists.html │ │ ├── 2022-09-11-lists.html │ │ ├── title-with-double-quotes-and-single-quotes-and-colons-but-forget-accents-aaaaaaa.html │ │ ├── 2023-12-01-title-with-double-quotes-and-single-quotes-and-colons-but-forget-accents-aaaaaaa.html │ │ ├── tables.html │ │ ├── 2022-09-17-tables.html │ │ ├── page-3.html │ │ ├── 2022-01-23-page-3.html │ │ ├── Page 2.html │ │ ├── page-2.html │ │ ├── 2022-01-23-page-2.html │ │ └── Page 1.html │ ├── my_site │ │ ├── _data │ │ │ └── dummy.yml │ │ ├── _config.yml │ │ └── _layouts │ │ │ └── post.html │ └── spec_cache │ │ ├── .pages_index.yml │ │ └── pages │ │ ├── page-3-6c9343606ef64b12abb6bb9dc0d53622.yml │ │ └── title-with-double-quotes-and-single-quotes-and-colons-but-forget-àccénts-àáâãäāăȧǎȁȃ-9349e5108c0e4c0ea772b187d63ecfe1.yml ├── support │ ├── skip_import.rb │ ├── golden_helper.rb │ ├── golden_examples.rb │ ├── page_examples.rb │ ├── collection_examples.rb │ └── page_data_examples.rb ├── integration │ ├── pages │ │ ├── multiple_pages_spec.rb │ │ ├── single_page_spec.rb │ │ └── preserve_existing_files_spec.rb │ ├── setup │ │ ├── missing_token_spec.rb │ │ ├── missing_configuration_spec.rb │ │ └── deprecated_options_spec.rb │ ├── cache │ │ └── cache_env_var_spec.rb │ └── databases │ │ ├── single_database_spec.rb │ │ ├── multiple_databases_spec.rb │ │ ├── database_edge_cases_spec.rb │ │ ├── preserve_existing_files_spec.rb │ │ ├── duplicate_databases_spec.rb │ │ └── filtered_database_spec.rb ├── README.md ├── spec_helper.rb └── unit │ ├── cacheable_spec.rb │ ├── generators │ ├── collection_spec.rb │ ├── data_spec.rb │ └── generator_spec.rb │ └── cassette_manager_spec.rb ├── lib ├── jekyll-notion │ ├── version.rb │ ├── document_without_a_file.rb │ ├── generators │ │ ├── collectionable.rb │ │ ├── page.rb │ │ ├── data.rb │ │ ├── generator.rb │ │ └── collection.rb │ ├── page_without_a_file.rb │ ├── cacheable.rb │ ├── cassette_manager.rb │ └── generator.rb └── jekyll-notion.rb ├── renovate.json ├── .rubocop.yml ├── Gemfile ├── jekyll-notion.gemspec ├── LICENSE ├── .gitignore ├── .github └── workflows │ └── ruby.yml ├── .rubocop_todo.yml ├── Gemfile.lock └── README.md /.ruby-version: -------------------------------------------------------------------------------- 1 | 2.7.8 2 | -------------------------------------------------------------------------------- /spec/fixtures/my_site_2/_config.yml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/fixtures/my_site_2/page-1.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Page 1 3 | --- 4 | -------------------------------------------------------------------------------- /spec/fixtures/my_site_2/page-2.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: page 2 3 | --- 4 | -------------------------------------------------------------------------------- /spec/fixtures/golden/2022-01-01-my-post.html: -------------------------------------------------------------------------------- 1 |
Wow, what an amazing article!
2 | -------------------------------------------------------------------------------- /spec/fixtures/my_site/_data/dummy.yml: -------------------------------------------------------------------------------- 1 | - alpha: one 2 | beta: two 3 | gamma: three 4 | -------------------------------------------------------------------------------- /spec/fixtures/my_site_2/_posts/2022-01-01-my-post.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: My Post 3 | --- 4 | Wow, what an amazing article! 5 | -------------------------------------------------------------------------------- /lib/jekyll-notion/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module JekyllNotion 4 | VERSION = "3.0.0.beta1" 5 | end 6 | -------------------------------------------------------------------------------- /spec/fixtures/my_site_2/_posts/2022-01-23-page-1.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Page 1 3 | --- 4 | This post is a clone from the notion database 5 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:base" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /spec/fixtures/my_site/_config.yml: -------------------------------------------------------------------------------- 1 | defaults: 2 | - 3 | scope: 4 | path: "" 5 | type: pages 6 | values: 7 | layout: some_default 8 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_from: .rubocop_todo.yml 2 | 3 | require: rubocop-jekyll 4 | inherit_gem: 5 | rubocop-jekyll: .rubocop.yml 6 | 7 | AllCops: 8 | NewCops: enable 9 | 10 | -------------------------------------------------------------------------------- /spec/fixtures/my_site/_layouts/post.html: -------------------------------------------------------------------------------- 1 | --- 2 | --- 3 | 4 | 5 |Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin nec sapien porttitor, aliquet est dignissim, dapibus libero. Integer sapien tortor, volutpat sed efficitur sit amet, sagittis ut nisi. Nunc sollicitudin condimentum finibus. Cras auctor sit amet leo at porttitor. Donec dignissim laoreet tortor, ullamcorper elementum magna aliquam a. Maecenas viverra, risus eu posuere fringilla, est odio viverra nisl, sit amet accumsan erat metus egestas massa. Suspendisse iaculis, ipsum eget viverra suscipit, tortor felis finibus nulla, at mattis odio mi tincidunt sapien. Mauris quis augue quis risus elementum dapibus eu ut leo. Etiam leo neque, eleifend sit amet rutrum in, malesuada in turpis. Donec ex justo, fringilla at cursus id, fermentum eget ante. Nam vitae sapien velit.
2 | 3 |Nullam vestibulum turpis at dignissim dapibus. In nec velit finibus, egestas purus nec, accumsan enim. Maecenas in congue massa. Maecenas ipsum mauris, aliquet quis ex eu, ornare feugiat ex. Sed convallis ullamcorper ipsum, vel ullamcorper diam imperdiet vitae. Etiam efficitur neque in nibh elementum, non cursus tellus maximus. Duis magna dui, mattis vitae felis non, fringilla accumsan erat. Duis scelerisque scelerisque viverra. Sed luctus, mi suscipit bibendum luctus, sem purus dictum ante, eu cursus ipsum quam sit amet augue.
4 | 5 |Praesent rhoncus enim dolor, nec tincidunt lacus mollis ut. Phasellus eu ex leo. Donec nec vulputate nisl. Fusce mattis blandit sem, ac molestie mi placerat quis. Donec lacinia enim sed nunc pulvinar mattis. Etiam dictum neque quis lacus congue pulvinar. Nam lectus purus, egestas id feugiat et, molestie sed lacus. Sed laoreet molestie dolor nec tincidunt. In imperdiet dolor sit amet elit placerat, et ullamcorper sapien rutrum. Sed elit tellus, rhoncus id erat in, lobortis volutpat ex. Nullam sit amet commodo lectus, vitae finibus augue. Integer venenatis lectus ut placerat maximus. Etiam hendrerit nisl eros, eu ultricies magna malesuada non. Maecenas at quam id elit elementum cursus. Cras fermentum arcu purus, ac vulputate sem pretium id. Ut placerat metus turpis, id aliquet sapien placerat eget.
6 | 7 | -------------------------------------------------------------------------------- /spec/fixtures/golden/2023-12-01-title-with-double-quotes-and-single-quotes-and-colons-but-forget-accents-aaaaaaa.html: -------------------------------------------------------------------------------- 1 |Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin nec sapien porttitor, aliquet est dignissim, dapibus libero. Integer sapien tortor, volutpat sed efficitur sit amet, sagittis ut nisi. Nunc sollicitudin condimentum finibus. Cras auctor sit amet leo at porttitor. Donec dignissim laoreet tortor, ullamcorper elementum magna aliquam a. Maecenas viverra, risus eu posuere fringilla, est odio viverra nisl, sit amet accumsan erat metus egestas massa. Suspendisse iaculis, ipsum eget viverra suscipit, tortor felis finibus nulla, at mattis odio mi tincidunt sapien. Mauris quis augue quis risus elementum dapibus eu ut leo. Etiam leo neque, eleifend sit amet rutrum in, malesuada in turpis. Donec ex justo, fringilla at cursus id, fermentum eget ante. Nam vitae sapien velit.
2 | 3 |Nullam vestibulum turpis at dignissim dapibus. In nec velit finibus, egestas purus nec, accumsan enim. Maecenas in congue massa. Maecenas ipsum mauris, aliquet quis ex eu, ornare feugiat ex. Sed convallis ullamcorper ipsum, vel ullamcorper diam imperdiet vitae. Etiam efficitur neque in nibh elementum, non cursus tellus maximus. Duis magna dui, mattis vitae felis non, fringilla accumsan erat. Duis scelerisque scelerisque viverra. Sed luctus, mi suscipit bibendum luctus, sem purus dictum ante, eu cursus ipsum quam sit amet augue.
4 | 5 |Praesent rhoncus enim dolor, nec tincidunt lacus mollis ut. Phasellus eu ex leo. Donec nec vulputate nisl. Fusce mattis blandit sem, ac molestie mi placerat quis. Donec lacinia enim sed nunc pulvinar mattis. Etiam dictum neque quis lacus congue pulvinar. Nam lectus purus, egestas id feugiat et, molestie sed lacus. Sed laoreet molestie dolor nec tincidunt. In imperdiet dolor sit amet elit placerat, et ullamcorper sapien rutrum. Sed elit tellus, rhoncus id erat in, lobortis volutpat ex. Nullam sit amet commodo lectus, vitae finibus augue. Integer venenatis lectus ut placerat maximus. Etiam hendrerit nisl eros, eu ultricies magna malesuada non. Maecenas at quam id elit elementum cursus. Cras fermentum arcu purus, ac vulputate sem pretium id. Ut placerat metus turpis, id aliquet sapien placerat eget.
6 | 7 | -------------------------------------------------------------------------------- /spec/fixtures/golden/tables.html: -------------------------------------------------------------------------------- 1 || 0.0 cell | 5 |0.1 cell | 6 |
| 1.0 cell | 9 |1.1 cell | 10 |
| 2.0 cell | 13 |2.1 cell | 14 |
| header 0 | 22 |header 1 | 23 |header 2 | 24 |
|---|---|---|
| 0.0 cell | 29 |1.0 cell | 30 |2.0 cell | 31 |
| 0.1 cell | 34 |1.1 cell | 35 |2.1 cell | 36 |
| 44 | | header 1 | 45 |header 2 | 46 |
|---|---|---|
| row 1 | 51 |1.0 cell | 52 |2.0 cell | 53 |
| row 2 | 56 |1.1 cell | 57 |2.1 cell | 58 |
This is a paragraph
63 | 64 |and this is a nested paragraph
65 |
66 | with a third level nested paragraph
67 |
68 | <br />
69 |
70 | <br />
71 | | 0.0 cell | 5 |0.1 cell | 6 |
| 1.0 cell | 9 |1.1 cell | 10 |
| 2.0 cell | 13 |2.1 cell | 14 |
| header 0 | 22 |header 1 | 23 |header 2 | 24 |
|---|---|---|
| 0.0 cell | 29 |1.0 cell | 30 |2.0 cell | 31 |
| 0.1 cell | 34 |1.1 cell | 35 |2.1 cell | 36 |
| 44 | | header 1 | 45 |header 2 | 46 |
|---|---|---|
| row 1 | 51 |1.0 cell | 52 |2.0 cell | 53 |
| row 2 | 56 |1.1 cell | 57 |2.1 cell | 58 |
This is a paragraph
63 | 64 |and this is a nested paragraph
65 |
66 | with a third level nested paragraph
67 |
68 | <br />
69 |
70 | <br />
71 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer tempus semper risus, non iaculis nisi. Praesent ut magna auctor, consequat metus in, hendrerit ligula. Maecenas sagittis pulvinar metus, ut blandit ex tincidunt quis. Quisque ullamcorper urna sapien, vitae malesuada libero auctor eget. Etiam eu neque tellus. Nam et purus at orci aliquam malesuada non a libero. Vivamus at condimentum dolor. Duis blandit tincidunt quam, quis pellentesque tellus auctor in. Praesent vel ligula felis. Ut pellentesque scelerisque metus vitae vehicula. Vivamus lacinia rhoncus maximus. Duis id ligula et ex suscipit tincidunt.
2 | 3 |Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer tempus semper risus, non iaculis nisi. Praesent ut magna auctor, consequat metus in, hendrerit ligula. Maecenas sagittis pulvinar metus, ut blandit ex tincidunt quis. Quisque ullamcorper urna sapien, vitae malesuada libero auctor eget. Etiam eu neque tellus. Nam et purus at orci aliquam malesuada non a libero. Vivamus at condimentum dolor. Duis blandit tincidunt quam, quis pellentesque tellus auctor in. Praesent vel ligula felis. Ut pellentesque scelerisque metus vitae vehicula. Vivamus lacinia rhoncus maximus. Duis id ligula et ex suscipit tincidunt.
4 | 5 |Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer tempus semper risus, non iaculis nisi. Praesent ut magna auctor, consequat metus in, hendrerit ligula. Maecenas sagittis pulvinar metus, ut blandit ex tincidunt quis. Quisque ullamcorper urna sapien, vitae malesuada libero auctor eget. Etiam eu neque tellus. Nam et purus at orci aliquam malesuada non a libero. Vivamus at condimentum dolor. Duis blandit tincidunt quam, quis pellentesque tellus auctor in. Praesent vel ligula felis. Ut pellentesque scelerisque metus vitae vehicula. Vivamus lacinia rhoncus maximus. Duis id ligula et ex suscipit tincidunt.
6 | 7 |Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer tempus semper risus, non iaculis nisi. Praesent ut magna auctor, consequat metus in, hendrerit ligula. Maecenas sagittis pulvinar metus, ut blandit ex tincidunt quis. Quisque ullamcorper urna sapien, vitae malesuada libero auctor eget. Etiam eu neque tellus. Nam et purus at orci aliquam malesuada non a libero. Vivamus at condimentum dolor. Duis blandit tincidunt quam, quis pellentesque tellus auctor in. Praesent vel ligula felis. Ut pellentesque scelerisque metus vitae vehicula. Vivamus lacinia rhoncus maximus. Duis id ligula et ex suscipit tincidunt.
8 | 9 | -------------------------------------------------------------------------------- /spec/fixtures/golden/2022-01-23-page-3.html: -------------------------------------------------------------------------------- 1 |Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer tempus semper risus, non iaculis nisi. Praesent ut magna auctor, consequat metus in, hendrerit ligula. Maecenas sagittis pulvinar metus, ut blandit ex tincidunt quis. Quisque ullamcorper urna sapien, vitae malesuada libero auctor eget. Etiam eu neque tellus. Nam et purus at orci aliquam malesuada non a libero. Vivamus at condimentum dolor. Duis blandit tincidunt quam, quis pellentesque tellus auctor in. Praesent vel ligula felis. Ut pellentesque scelerisque metus vitae vehicula. Vivamus lacinia rhoncus maximus. Duis id ligula et ex suscipit tincidunt.
2 | 3 |Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer tempus semper risus, non iaculis nisi. Praesent ut magna auctor, consequat metus in, hendrerit ligula. Maecenas sagittis pulvinar metus, ut blandit ex tincidunt quis. Quisque ullamcorper urna sapien, vitae malesuada libero auctor eget. Etiam eu neque tellus. Nam et purus at orci aliquam malesuada non a libero. Vivamus at condimentum dolor. Duis blandit tincidunt quam, quis pellentesque tellus auctor in. Praesent vel ligula felis. Ut pellentesque scelerisque metus vitae vehicula. Vivamus lacinia rhoncus maximus. Duis id ligula et ex suscipit tincidunt.
4 | 5 |Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer tempus semper risus, non iaculis nisi. Praesent ut magna auctor, consequat metus in, hendrerit ligula. Maecenas sagittis pulvinar metus, ut blandit ex tincidunt quis. Quisque ullamcorper urna sapien, vitae malesuada libero auctor eget. Etiam eu neque tellus. Nam et purus at orci aliquam malesuada non a libero. Vivamus at condimentum dolor. Duis blandit tincidunt quam, quis pellentesque tellus auctor in. Praesent vel ligula felis. Ut pellentesque scelerisque metus vitae vehicula. Vivamus lacinia rhoncus maximus. Duis id ligula et ex suscipit tincidunt.
6 | 7 |Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer tempus semper risus, non iaculis nisi. Praesent ut magna auctor, consequat metus in, hendrerit ligula. Maecenas sagittis pulvinar metus, ut blandit ex tincidunt quis. Quisque ullamcorper urna sapien, vitae malesuada libero auctor eget. Etiam eu neque tellus. Nam et purus at orci aliquam malesuada non a libero. Vivamus at condimentum dolor. Duis blandit tincidunt quam, quis pellentesque tellus auctor in. Praesent vel ligula felis. Ut pellentesque scelerisque metus vitae vehicula. Vivamus lacinia rhoncus maximus. Duis id ligula et ex suscipit tincidunt.
8 | 9 | -------------------------------------------------------------------------------- /spec/README.md: -------------------------------------------------------------------------------- 1 | # Testing Guide 2 | 3 | This guide explains the testing structure for jekyll-notion plugin. 4 | 5 | ## Test Types 6 | 7 | ### Unit Tests (`spec/unit/`) 8 | 9 | Unit tests validate individual classes, modules, and methods in isolation without invoking the full Jekyll site generation process. 10 | 11 | **Classification Rule:** 12 | - Does **NOT** call `site.process` 13 | - Tests individual methods, classes, or modules directly 14 | - Uses mocking/stubbing to isolate the system under test 15 | - Focuses on specific functionality without framework dependencies 16 | 17 | ```ruby 18 | # Testing individual methods without site.process 19 | expect(JekyllNotion::Cacheable.enabled?).to be true 20 | instance.call(id: "test-123") 21 | ``` 22 | 23 | ### Integration Tests (`spec/integration/`) 24 | 25 | Integration tests validate the plugin's behavior during a full Jekyll site build (`site.process`), ensuring components interact correctly. 26 | 27 | **Classification Rule:** 28 | - Calls `site.process` 29 | - Exercises the plugin inside Jekyll's ecosystem 30 | - Validates multiple components working together 31 | 32 | ```ruby 33 | # Testing plugin behavior during Jekyll site generation 34 | site = Jekyll::Site.new(config) 35 | site.process # ← This makes it an integration test 36 | 37 | # Testing generated content after full site build 38 | expect(site.pages).not_to be_empty 39 | expect(site.posts.first.content).to include("notion content") 40 | ``` 41 | 42 | > [!TIP] 43 | > Does it call `site.process`? → ✅ Integration (`spec/integration/`) 44 | > Otherwise → ✅ Unit (`spec/unit/`) 45 | 46 | ## VCR Configuration 47 | 48 | The plugin uses [VCR](https://benoittgt.github.io/vcr) to record/replay Notion API calls with environment-specific configurations. 49 | 50 | ### Environment-Aware VCR Setup 51 | 52 | VCR configuration automatically adapts based on test type: 53 | 54 | - **Integration Tests** (`spec/integration/`): Use webmock + shared cassettes in `spec/fixtures/spec_cache/` 55 | - **Unit Tests** (`spec/unit/`): Use faraday + isolated temporary directories for complete isolation 56 | 57 | The system detects test type by file path and applies appropriate configuration automatically. 58 | 59 | ### How VCR Works 60 | 61 | #### Database/Other API Calls 62 | Must be wrapped in a cassette: 63 | 64 | ```ruby 65 | # For database requests and other non-page API calls 66 | VCR.use_cassette("notion_database") { site.process } 67 | ``` 68 | 69 | > [!TIP] 70 | > Wrap `site.process` when the `databases` key is declared in `_config.yml`. 71 | 72 | -------------------------------------------------------------------------------- /spec/integration/pages/preserve_existing_files_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe "Pages: preserve existing pages" do 6 | let(:site) { Jekyll::Site.new(config) } 7 | let(:config) do 8 | Jekyll.configuration( 9 | "source" => SOURCE_DIR_2, # contains a local "Page 1" 10 | "destination" => DEST_DIR, 11 | "notion" => { 12 | "pages" => [ 13 | { "id" => "9dc17c9c-9d2e-469d-bbf0-f9648f3288d3" }, # Notion "Page 1" 14 | ], 15 | } 16 | ) 17 | end 18 | 19 | before do 20 | allow(Jekyll.logger).to receive(:warn) 21 | site.process 22 | end 23 | 24 | it "logs a warning when a page with the same title exists" do 25 | expect(Jekyll.logger).to have_received(:warn).with( 26 | a_string_matching(%r!Jekyll Notion:!i), 27 | a_string_matching(%r!Page `Page 1` .*skipping .*Notion import!i) 28 | ) 29 | end 30 | 31 | it "does not add a duplicate page" do 32 | matching = site.pages.select { |p| p.data["title"].downcase == "page 1" } 33 | expect(matching.size).to eq(1) 34 | end 35 | 36 | it "keeps the local file contents" do 37 | page = site.pages.find { |p| p.data["title"].downcase == "page 1" } 38 | expect(page.instance_variable_get(:@base)).to start_with(SOURCE_DIR_2) # ensures it’s the local file 39 | end 40 | 41 | context "When the titles are not in the same case" do 42 | let(:config) do 43 | Jekyll.configuration( 44 | "source" => SOURCE_DIR_2, # contains a local "page 2" 45 | "destination" => DEST_DIR, 46 | "notion" => { 47 | "pages" => [ 48 | { "id" => "0b8c4501209246c1b800529623746afc" }, # Notion "Page 2" 49 | ], 50 | } 51 | ) 52 | end 53 | 54 | it "still logs a warning treating titles case-insensitively" do 55 | expect(Jekyll.logger).to have_received(:warn).with( 56 | a_string_matching(%r!Jekyll Notion:!i), 57 | a_string_matching(%r!Page `Page 2` .*skipping .*Notion import!i) 58 | ) 59 | end 60 | 61 | it "does not add a duplicate even if the cases differ" do 62 | matching = site.pages.select { |p| p.data["title"].downcase == "page 2" } 63 | expect(matching.size).to eq(1) 64 | end 65 | 66 | it "keeps the local file contents (case-insensitive match)" do 67 | page = site.pages.find { |p| p.data["title"].downcase == "page 2" } 68 | expect(page.instance_variable_get(:@base)).to start_with(SOURCE_DIR_2) # still the local file 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "jekyll" 4 | require File.expand_path("../lib/jekyll-notion", __dir__) 5 | require "simplecov" 6 | require "webmock/rspec" 7 | require "vcr" 8 | require "tmpdir" 9 | require "fileutils" 10 | 11 | SimpleCov.start do 12 | enable_coverage :branch 13 | add_filter "spec/" 14 | end 15 | 16 | ENV["JEKYLL_ENV"] = "test" 17 | 18 | Jekyll.logger.log_level = :error 19 | 20 | RSpec.configure do |config| 21 | # Load support files 22 | Dir[ 23 | File.join(__dir__, "support/**/*.rb"), 24 | ].sort.each { |f| require f } 25 | 26 | config.include GoldenHelper 27 | config.run_all_when_everything_filtered = true 28 | config.filter_run :focus 29 | 30 | SOURCE_DIR = File.expand_path("fixtures/my_site", __dir__) 31 | SOURCE_DIR_2 = File.expand_path("fixtures/my_site_2", __dir__) 32 | DEST_DIR = Dir.mktmpdir("jekyll-site") 33 | end 34 | 35 | # VCR configuration override with conditional logic 36 | # This prepend module adds environment-aware VCR configuration 37 | JekyllNotion::Cacheable.singleton_class.prepend(Module.new do 38 | def configure_vcr 39 | # Detect if we're in an integration test context by checking file path 40 | integration_test = if defined?(RSpec) && RSpec.current_example 41 | file_path = RSpec.current_example.example_group.metadata[:file_path] 42 | file_path&.include?("spec/integration/") 43 | else 44 | false 45 | end 46 | 47 | if integration_test 48 | # Integration test VCR configuration 49 | target_dir = "spec/fixtures/spec_cache" 50 | 51 | VCR.configure do |config| 52 | config.cassette_library_dir = target_dir 53 | config.hook_into :webmock 54 | 55 | # Redact the Notion token from the VCR cassettes 56 | config.filter_sensitive_data("[AUTH_REDACTED]") do |interaction| 57 | interaction.request.headers["Authorization"]&.first 58 | end 59 | 60 | # Redact cookies from the VCR cassettes 61 | config.before_record do |interaction| 62 | interaction.response.headers["Set-Cookie"]&.map! { |_cookie| "[COOKIE_REDACTED]" } 63 | end 64 | 65 | config.default_cassette_options = { 66 | :record => :new_episodes, 67 | :allow_playback_repeats => true, 68 | :match_requests_on => [:method, :uri, :body], 69 | } 70 | end 71 | else 72 | # Use the original production VCR configuration 73 | super 74 | end 75 | end 76 | end) 77 | -------------------------------------------------------------------------------- /spec/support/page_examples.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.shared_examples "a jekyll page" do |page_name| 4 | let(:page) do 5 | site.pages.find { _1.basename == page_name } 6 | end 7 | 8 | it "stores id into page data" do 9 | expect(page.data).to include("id" => "9dc17c9c-9d2e-469d-bbf0-f9648f3288d3") 10 | end 11 | 12 | it "stores created_time into page data" do 13 | expect(page.data).to include("created_time" => Time.parse("2022-01-23 12:31:00.000000000 +0000")) 14 | end 15 | 16 | it "stores last_edited_time into page data" do 17 | expect(page.data).to include("last_edited_time" => Time.parse("2025-09-04 17:24:00.000000000 +0000")) 18 | end 19 | 20 | it "stores cover into page data" do 21 | expect(page.data).to include("cover" => "https://www.notion.so/images/page-cover/met_canaletto_1720.jpg") 22 | end 23 | 24 | it "stores icon into page data" do 25 | expect(page.data).to include("icon" => "💥") 26 | end 27 | 28 | it "stores archived into page data" do 29 | expect(page.data).to include("archived" => false) 30 | end 31 | 32 | it "stores multi_select into page data" do 33 | expected_value = %w(mselect1 mselect2 mselect3) 34 | expect(page.data).to include("multi_select" => expected_value) 35 | end 36 | 37 | it "stores select into page data" do 38 | expect(page.data).to include("select" => "select1") 39 | end 40 | 41 | it "stores people into page data" do 42 | expect(page.data).to include("person" => ["Enrique Moriarty"]) 43 | end 44 | 45 | it "stores number into page data" do 46 | expect(page.data).to include("numbers" => 12) 47 | end 48 | 49 | it "stores phone_number into page data" do 50 | expect(page.data).to include("phone" => 983_788_379) 51 | end 52 | 53 | it "stores files into page data" do 54 | expect(page.data).to include( 55 | "file" => array_including( 56 | start_with("https://prod-files-secure.s3.us-west-2.amazonaws.com/") 57 | ) 58 | ) 59 | end 60 | 61 | it "stores email into page data" do 62 | expect(page.data).to include("email" => "hola@test.com") 63 | end 64 | 65 | it "stores checkbox into page data" do 66 | expect(page.data).to include("checkbox" => false) 67 | end 68 | 69 | it "stores title into page data" do 70 | expect(page.data).to include("title" => "Page 1") 71 | end 72 | 73 | it "stores date into page data" do 74 | expect(page.data).to include("date" => Time.parse("2021-12-30")) 75 | end 76 | 77 | it "page is stored in destination directory" do 78 | expect(File).to exist(page.destination(".")) 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /spec/integration/setup/missing_configuration_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe "Setup: missing notion configuration" do 6 | let(:source_dir) { SOURCE_DIR } 7 | let(:dest_dir) { DEST_DIR } 8 | let(:site) { Jekyll::Site.new(config) } 9 | 10 | before do 11 | allow(Jekyll.logger).to receive(:warn) 12 | allow(Jekyll.logger).to receive(:error) 13 | 14 | site.process 15 | end 16 | 17 | context "when the notion property is not declared" do 18 | let(:config) do 19 | Jekyll.configuration( 20 | "source" => source_dir, 21 | "destination" => dest_dir 22 | ) 23 | end 24 | 25 | it_behaves_like "skips import" 26 | 27 | it "does not log any error" do 28 | expect(Jekyll.logger).not_to have_received(:error) 29 | end 30 | 31 | it "does not log any warning" do 32 | expect(Jekyll.logger).not_to have_received(:warn) 33 | end 34 | end 35 | 36 | context "when the notion property is declared but nil" do 37 | let(:config) do 38 | Jekyll.configuration( 39 | "source" => source_dir, 40 | "destination" => dest_dir, 41 | "notion" => nil 42 | ) 43 | end 44 | 45 | it_behaves_like "skips import" 46 | 47 | it "logs a warning about skipping import" do 48 | expect(Jekyll.logger).to have_received(:warn).with( 49 | a_string_matching(%r!Jekyll Notion:!i), 50 | a_string_matching(%r!skipping import!i) 51 | ) 52 | end 53 | end 54 | 55 | context "when notion > databases is nil" do 56 | let(:config) do 57 | Jekyll.configuration( 58 | "source" => source_dir, 59 | "destination" => dest_dir, 60 | "notion" => { "databases" => nil } 61 | ) 62 | end 63 | 64 | it "logs a warning about skipping import" do 65 | expect(Jekyll.logger).to have_received(:warn).with( 66 | a_string_matching(%r!Jekyll Notion:!i), 67 | a_string_matching(%r!skipping import!i) 68 | ) 69 | end 70 | 71 | it_behaves_like "skips import" 72 | end 73 | 74 | context "when notion > pages is nil" do 75 | let(:config) do 76 | Jekyll.configuration( 77 | "source" => source_dir, 78 | "destination" => dest_dir, 79 | "notion" => { "pages" => nil } 80 | ) 81 | end 82 | 83 | it "logs a warning about skipping import" do 84 | expect(Jekyll.logger).to have_received(:warn).with( 85 | a_string_matching(%r!Jekyll Notion:!i), 86 | a_string_matching(%r!skipping import!i) 87 | ) 88 | end 89 | 90 | it_behaves_like "skips import" 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /spec/integration/databases/single_database_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe "Databases: single database import" do 6 | let(:config) do 7 | Jekyll.configuration( 8 | "source" => SOURCE_DIR, 9 | "destination" => DEST_DIR, 10 | "notion" => { 11 | "databases" => [{ "id" => "1ae33dd5f3314402948069517fa40ae2" }], # Test Database 12 | } 13 | ) 14 | end 15 | 16 | let(:site) { Jekyll::Site.new(config) } 17 | 18 | before do 19 | VCR.use_cassette("notion_database") { site.process } 20 | end 21 | 22 | it_behaves_like "a collection is renderded correctly", "posts" 23 | it_behaves_like "a jekyll collection", "posts" 24 | 25 | it "imports database entries as posts with correct naming" do 26 | site.posts.each do |post| 27 | expect(post.path).to match(%r!_posts/\d{4}-\d{2}-\d{2}-.*.md$!) 28 | end 29 | end 30 | 31 | context "with custom collection" do 32 | let(:config) do 33 | Jekyll.configuration( 34 | "source" => SOURCE_DIR, 35 | "destination" => DEST_DIR, 36 | "collections" => { "articles" => { "output" => true } }, 37 | "notion" => { 38 | "databases" => [{ 39 | "id" => "1ae33dd5f3314402948069517fa40ae2", 40 | "collection" => "articles", 41 | }], 42 | } 43 | ) 44 | end 45 | 46 | it_behaves_like "a collection is renderded correctly", "articles" 47 | it_behaves_like "a jekyll collection", "articles" 48 | 49 | it "imports database entries as articles without date prefix" do 50 | site.collections["articles"].each do |article| 51 | expect(article.path).to match(%r!_articles/[^/]+\.md$!) 52 | expect(article.path).not_to match(%r!\d{4}-\d{2}-\d{2}!) 53 | end 54 | end 55 | end 56 | 57 | context "with data import" do 58 | let(:config) do 59 | Jekyll.configuration( 60 | "source" => SOURCE_DIR, 61 | "destination" => DEST_DIR, 62 | "notion" => { 63 | "databases" => [{ 64 | "id" => "1ae33dd5f3314402948069517fa40ae2", 65 | "data" => "test_database", 66 | }], 67 | } 68 | ) 69 | end 70 | 71 | it_behaves_like "a jekyll data array", "test_database" 72 | 73 | it "stores all database entries in data object" do 74 | expect(site.data["test_database"]).to be_an(Array) 75 | expect(site.data["test_database"].size).to be > 0 76 | end 77 | 78 | it "contains content property for each entry" do 79 | site.data["test_database"].each do |entry| 80 | expect(entry).to have_key("content") 81 | expect(entry).to have_key("title") 82 | end 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /spec/integration/setup/deprecated_options_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe "Setup: deprecated options" do 6 | subject(:build!) { site.process } 7 | 8 | let(:site) { Jekyll::Site.new(config) } 9 | 10 | before do 11 | allow(Jekyll.logger).to receive(:warn) 12 | 13 | VCR.use_cassette("notion_database") { build! } 14 | end 15 | 16 | context "with fetch_on_watch" do 17 | let(:config) do 18 | Jekyll.configuration( 19 | "source" => SOURCE_DIR, 20 | "destination" => DEST_DIR, 21 | "notion" => { 22 | "fetch_on_watch" => false, # deprecated 23 | "databases" => [{ "id" => "1ae33dd5f3314402948069517fa40ae2" }], 24 | "pages" => [{ "id" => "9dc17c9c-9d2e-469d-bbf0-f9648f3288d3" }], # Page 1 25 | } 26 | ) 27 | end 28 | 29 | it "logs a warning message" do 30 | expect(Jekyll.logger).to have_received(:warn).with( 31 | a_string_matching(%r!Jekyll Notion:!i), 32 | a_string_matching(%r!fetch_on_watch!i) 33 | ) 34 | end 35 | 36 | it_behaves_like "a page is rendered correctly", "Page 1" 37 | it_behaves_like "a collection is renderded correctly", "posts" 38 | end 39 | 40 | context "with database" do 41 | let(:config) do 42 | Jekyll.configuration( 43 | "source" => SOURCE_DIR, 44 | "destination" => DEST_DIR, 45 | "notion" => { 46 | "database" => [{ "id" => "1ae33dd5f3314402948069517fa40ae2" }], # deprecated 47 | "pages" => [{ "id" => "9dc17c9c-9d2e-469d-bbf0-f9648f3288d3" }], 48 | } 49 | ) 50 | end 51 | 52 | it "logs a warning message" do 53 | expect(Jekyll.logger).to have_received(:warn).with( 54 | a_string_matching(%r!Jekyll Notion:!i), 55 | a_string_matching(%r!`database` key is deprecated!i) 56 | ) 57 | end 58 | 59 | it_behaves_like "a page is rendered correctly", "Page 1" 60 | it_behaves_like "a collection is not renderded", "posts" 61 | end 62 | 63 | context "with page" do 64 | let(:config) do 65 | Jekyll.configuration( 66 | "source" => SOURCE_DIR, 67 | "destination" => DEST_DIR, 68 | "notion" => { 69 | "databases" => [{ "id" => "1ae33dd5f3314402948069517fa40ae2" }], 70 | "page" => { "id" => "9dc17c9c-9d2e-469d-bbf0-f9648f3288d3" }, # deprecated 71 | } 72 | ) 73 | end 74 | 75 | it "logs a warning message" do 76 | expect(Jekyll.logger).to have_received(:warn).with( 77 | a_string_matching(%r!Jekyll Notion:!i), 78 | a_string_matching(%r!`page` key is deprecated!i) 79 | ) 80 | end 81 | 82 | it_behaves_like "a page is not rendered", "Page 1" 83 | it_behaves_like "a collection is renderded correctly", "posts" 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /spec/fixtures/golden/Page 2.html: -------------------------------------------------------------------------------- 1 |Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer tempus semper risus, non iaculis nisi. Praesent ut magna auctor, consequat metus in, hendrerit ligula. Maecenas sagittis pulvinar metus, ut blandit ex tincidunt quis. Quisque ullamcorper urna sapien, vitae malesuada libero auctor eget. Etiam eu neque tellus. Nam et purus at orci aliquam malesuada non a libero. Vivamus at condimentum dolor. Duis blandit tincidunt quam, quis pellentesque tellus auctor in. Praesent vel ligula felis. Ut pellentesque scelerisque metus vitae vehicula. Vivamus lacinia rhoncus maximus. Duis id ligula et ex suscipit tincidunt.
4 | 5 |Donec mattis, magna at ornare laoreet, nisl nunc mollis est, vitae congue massa felis a lorem. Suspendisse et facilisis ante. Nunc sapien nunc, dictum non ex sed, accumsan dapibus orci. Morbi lacus dui, commodo eget felis id, lobortis tempus purus. Mauris viverra, est eget congue feugiat, nunc lectus imperdiet libero, vel vehicula mi mi gravida elit. Aenean mauris augue, aliquam eu quam sit amet, porttitor faucibus diam. Aenean vel dolor pellentesque, pellentesque nulla id, pretium lacus. Vivamus pretium quam et leo fermentum commodo. Quisque facilisis non est a mattis. Phasellus eget placerat urna, sit amet lacinia odio. Integer id auctor dolor, non sagittis velit. Aenean eu justo sit amet dolor gravida efficitur.
8 | 9 |Proin aliquet lobortis ex. Ut eget purus blandit, cursus diam at, tincidunt tortor. Suspendisse accumsan nulla ipsum, ac rutrum ipsum pellentesque eget. Praesent in dolor mollis, scelerisque nulla quis, viverra est. Nam ut suscipit est. Etiam in elit quis sapien tincidunt porta. Donec sed rutrum felis. Integer turpis sapien, viverra a leo id, molestie ornare metus. Nulla ac libero tincidunt, maximus neque eu, elementum lectus. Maecenas et urna at quam facilisis laoreet a sit amet tortor. Suspendisse eget maximus lacus, ac facilisis arcu. Suspendisse condimentum, mi mattis varius scelerisque, augue leo ultricies felis, sit amet feugiat leo turpis a nisi. Nulla eget enim accumsan, laoreet lectus non, faucibus lacus. Vivamus id justo nisl.
12 | 13 |In hac habitasse platea dictumst. Praesent et orci sapien. Morbi lobortis hendrerit tortor eu placerat. Curabitur interdum tortor et dictum hendrerit. Morbi feugiat lectus ac libero bibendum, auctor volutpat sem ultrices. Cras tincidunt congue sodales. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse et aliquam diam. Phasellus at massa nec dolor mollis laoreet. Donec elementum quam hendrerit dui sodales, id facilisis odio porttitor. Vestibulum id sollicitudin tortor. Vivamus non ex erat. Donec varius erat nec ex pharetra luctus. In sodales sagittis sollicitudin. Proin eu ullamcorper ante. Curabitur pharetra at felis eget congue.
16 | 17 |la ncncsalmclasm cl;a ca a
18 | 19 | -------------------------------------------------------------------------------- /spec/fixtures/golden/page-2.html: -------------------------------------------------------------------------------- 1 |Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer tempus semper risus, non iaculis nisi. Praesent ut magna auctor, consequat metus in, hendrerit ligula. Maecenas sagittis pulvinar metus, ut blandit ex tincidunt quis. Quisque ullamcorper urna sapien, vitae malesuada libero auctor eget. Etiam eu neque tellus. Nam et purus at orci aliquam malesuada non a libero. Vivamus at condimentum dolor. Duis blandit tincidunt quam, quis pellentesque tellus auctor in. Praesent vel ligula felis. Ut pellentesque scelerisque metus vitae vehicula. Vivamus lacinia rhoncus maximus. Duis id ligula et ex suscipit tincidunt.
4 | 5 |Donec mattis, magna at ornare laoreet, nisl nunc mollis est, vitae congue massa felis a lorem. Suspendisse et facilisis ante. Nunc sapien nunc, dictum non ex sed, accumsan dapibus orci. Morbi lacus dui, commodo eget felis id, lobortis tempus purus. Mauris viverra, est eget congue feugiat, nunc lectus imperdiet libero, vel vehicula mi mi gravida elit. Aenean mauris augue, aliquam eu quam sit amet, porttitor faucibus diam. Aenean vel dolor pellentesque, pellentesque nulla id, pretium lacus. Vivamus pretium quam et leo fermentum commodo. Quisque facilisis non est a mattis. Phasellus eget placerat urna, sit amet lacinia odio. Integer id auctor dolor, non sagittis velit. Aenean eu justo sit amet dolor gravida efficitur.
8 | 9 |Proin aliquet lobortis ex. Ut eget purus blandit, cursus diam at, tincidunt tortor. Suspendisse accumsan nulla ipsum, ac rutrum ipsum pellentesque eget. Praesent in dolor mollis, scelerisque nulla quis, viverra est. Nam ut suscipit est. Etiam in elit quis sapien tincidunt porta. Donec sed rutrum felis. Integer turpis sapien, viverra a leo id, molestie ornare metus. Nulla ac libero tincidunt, maximus neque eu, elementum lectus. Maecenas et urna at quam facilisis laoreet a sit amet tortor. Suspendisse eget maximus lacus, ac facilisis arcu. Suspendisse condimentum, mi mattis varius scelerisque, augue leo ultricies felis, sit amet feugiat leo turpis a nisi. Nulla eget enim accumsan, laoreet lectus non, faucibus lacus. Vivamus id justo nisl.
12 | 13 |In hac habitasse platea dictumst. Praesent et orci sapien. Morbi lobortis hendrerit tortor eu placerat. Curabitur interdum tortor et dictum hendrerit. Morbi feugiat lectus ac libero bibendum, auctor volutpat sem ultrices. Cras tincidunt congue sodales. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse et aliquam diam. Phasellus at massa nec dolor mollis laoreet. Donec elementum quam hendrerit dui sodales, id facilisis odio porttitor. Vestibulum id sollicitudin tortor. Vivamus non ex erat. Donec varius erat nec ex pharetra luctus. In sodales sagittis sollicitudin. Proin eu ullamcorper ante. Curabitur pharetra at felis eget congue.
16 | 17 |la ncncsalmclasm cl;a ca a
18 | 19 | -------------------------------------------------------------------------------- /spec/integration/databases/multiple_databases_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe "Databases: multiple databases import" do 6 | let(:config) do 7 | Jekyll.configuration( 8 | "source" => SOURCE_DIR, 9 | "destination" => DEST_DIR, 10 | "collections" => { "articles" => { "output" => true } }, 11 | "notion" => { 12 | "databases" => [ 13 | { "id" => "1ae33dd5f3314402948069517fa40ae2" }, # Default to posts 14 | { 15 | "id" => "1ae33dd5f3314402948069517fa40ae2", # Same database to different collection 16 | "collection" => "articles", 17 | }, 18 | ], 19 | } 20 | ) 21 | end 22 | 23 | let(:site) { Jekyll::Site.new(config) } 24 | 25 | before do 26 | VCR.use_cassette("notion_database") { site.process } 27 | end 28 | 29 | it_behaves_like "a collection is renderded correctly", "posts" 30 | it_behaves_like "a collection is renderded correctly", "articles" 31 | it_behaves_like "a jekyll collection", "posts" 32 | it_behaves_like "a jekyll collection", "articles" 33 | 34 | it "imports to both posts and articles collections" do 35 | expect(site.posts.size).to be > 0 36 | expect(site.collections["articles"].size).to be > 0 37 | end 38 | 39 | it "maintains separate collections" do 40 | post_titles = site.posts.map(&:data).map { |d| d["title"] } 41 | article_titles = site.collections["articles"].map(&:data).map { |d| d["title"] } 42 | 43 | # Same database imported to different collections should have same content 44 | expect(post_titles).to match_array(article_titles) 45 | end 46 | 47 | context "with mixed collections and data import" do 48 | let(:config) do 49 | Jekyll.configuration( 50 | "source" => SOURCE_DIR, 51 | "destination" => DEST_DIR, 52 | "collections" => { "articles" => { "output" => true } }, 53 | "notion" => { 54 | "databases" => [ 55 | { "id" => "1ae33dd5f3314402948069517fa40ae2" }, # Default to posts 56 | { 57 | "id" => "1ae33dd5f3314402948069517fa40ae2", 58 | "collection" => "articles", 59 | }, 60 | { 61 | "id" => "1ae33dd5f3314402948069517fa40ae2", 62 | "data" => "database_entries", 63 | }, 64 | ], 65 | } 66 | ) 67 | end 68 | 69 | it_behaves_like "a collection is renderded correctly", "posts" 70 | it_behaves_like "a collection is renderded correctly", "articles" 71 | it_behaves_like "a jekyll data array", "database_entries" 72 | 73 | it "imports to collections and data simultaneously" do 74 | expect(site.posts.size).to be > 0 75 | expect(site.collections["articles"].size).to be > 0 76 | expect(site.data["database_entries"]).to be_an(Array) 77 | expect(site.data["database_entries"].size).to be > 0 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /spec/fixtures/golden/2022-01-23-page-2.html: -------------------------------------------------------------------------------- 1 |Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer tempus semper risus, non iaculis nisi. Praesent ut magna auctor, consequat metus in, hendrerit ligula. Maecenas sagittis pulvinar metus, ut blandit ex tincidunt quis. Quisque ullamcorper urna sapien, vitae malesuada libero auctor eget. Etiam eu neque tellus. Nam et purus at orci aliquam malesuada non a libero. Vivamus at condimentum dolor. Duis blandit tincidunt quam, quis pellentesque tellus auctor in. Praesent vel ligula felis. Ut pellentesque scelerisque metus vitae vehicula. Vivamus lacinia rhoncus maximus. Duis id ligula et ex suscipit tincidunt.
4 | 5 |Donec mattis, magna at ornare laoreet, nisl nunc mollis est, vitae congue massa felis a lorem. Suspendisse et facilisis ante. Nunc sapien nunc, dictum non ex sed, accumsan dapibus orci. Morbi lacus dui, commodo eget felis id, lobortis tempus purus. Mauris viverra, est eget congue feugiat, nunc lectus imperdiet libero, vel vehicula mi mi gravida elit. Aenean mauris augue, aliquam eu quam sit amet, porttitor faucibus diam. Aenean vel dolor pellentesque, pellentesque nulla id, pretium lacus. Vivamus pretium quam et leo fermentum commodo. Quisque facilisis non est a mattis. Phasellus eget placerat urna, sit amet lacinia odio. Integer id auctor dolor, non sagittis velit. Aenean eu justo sit amet dolor gravida efficitur.
8 | 9 |Proin aliquet lobortis ex. Ut eget purus blandit, cursus diam at, tincidunt tortor. Suspendisse accumsan nulla ipsum, ac rutrum ipsum pellentesque eget. Praesent in dolor mollis, scelerisque nulla quis, viverra est. Nam ut suscipit est. Etiam in elit quis sapien tincidunt porta. Donec sed rutrum felis. Integer turpis sapien, viverra a leo id, molestie ornare metus. Nulla ac libero tincidunt, maximus neque eu, elementum lectus. Maecenas et urna at quam facilisis laoreet a sit amet tortor. Suspendisse eget maximus lacus, ac facilisis arcu. Suspendisse condimentum, mi mattis varius scelerisque, augue leo ultricies felis, sit amet feugiat leo turpis a nisi. Nulla eget enim accumsan, laoreet lectus non, faucibus lacus. Vivamus id justo nisl.
12 | 13 |In hac habitasse platea dictumst. Praesent et orci sapien. Morbi lobortis hendrerit tortor eu placerat. Curabitur interdum tortor et dictum hendrerit. Morbi feugiat lectus ac libero bibendum, auctor volutpat sem ultrices. Cras tincidunt congue sodales. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse et aliquam diam. Phasellus at massa nec dolor mollis laoreet. Donec elementum quam hendrerit dui sodales, id facilisis odio porttitor. Vestibulum id sollicitudin tortor. Vivamus non ex erat. Donec varius erat nec ex pharetra luctus. In sodales sagittis sollicitudin. Proin eu ullamcorper ante. Curabitur pharetra at felis eget congue.
16 | 17 |la ncncsalmclasm cl;a ca a
18 | 19 | -------------------------------------------------------------------------------- /spec/integration/databases/database_edge_cases_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe "Databases: edge cases and error handling" do 6 | context "with posts collection containing date-based filenames" do 7 | let(:config) do 8 | Jekyll.configuration( 9 | "source" => SOURCE_DIR, 10 | "destination" => DEST_DIR, 11 | "notion" => { 12 | "databases" => [{ 13 | "id" => "1ae33dd5f3314402948069517fa40ae2", 14 | }], 15 | } 16 | ) 17 | end 18 | 19 | let(:site) { Jekyll::Site.new(config) } 20 | 21 | before do 22 | VCR.use_cassette("notion_database") { site.process } 23 | end 24 | 25 | it "generates date-prefixed filenames for posts" do 26 | site.posts.each do |post| 27 | expect(post.path).to match(%r!_posts/\d{4}-\d{2}-\d{2}-.+\.md$!) 28 | end 29 | end 30 | 31 | it "uses page date property when available" do 32 | # Find a post that has a specific date property 33 | dated_post = site.posts.find { |p| p.data["title"] == "Page 1" } 34 | expect(dated_post.path).to match(%r!2022-01-23-page-1\.md$!) 35 | end 36 | 37 | it "falls back to created_time when date property is missing" do 38 | # Find a post without explicit date property 39 | created_time_post = site.posts.find { |p| p.data["title"] == "tables" } 40 | expect(created_time_post.path).to match(%r!2022-09-17-tables\.md$!) 41 | end 42 | end 43 | 44 | context "with custom collection using non-date filenames" do 45 | let(:config) do 46 | Jekyll.configuration( 47 | "source" => SOURCE_DIR, 48 | "destination" => DEST_DIR, 49 | "collections" => { "docs" => { "output" => true } }, 50 | "notion" => { 51 | "databases" => [{ 52 | "id" => "1ae33dd5f3314402948069517fa40ae2", 53 | "collection" => "docs", 54 | }], 55 | } 56 | ) 57 | end 58 | 59 | let(:site) { Jekyll::Site.new(config) } 60 | 61 | before do 62 | VCR.use_cassette("notion_database") { site.process } 63 | end 64 | 65 | it "generates non-date filenames for custom collections" do 66 | site.collections["docs"].each do |doc| 67 | expect(doc.path).to match(%r!_docs/[^/]+\.md$!) 68 | expect(doc.path).not_to match(%r!\d{4}-\d{2}-\d{2}!) 69 | end 70 | end 71 | 72 | it "uses slugified titles for filenames" do 73 | # Find a document with special characters in title 74 | special_doc = site.collections["docs"].find do |d| 75 | d.data["title"].include?('"') || d.data["title"].include?("'") || d.data["title"].include?(":") 76 | end 77 | 78 | if special_doc 79 | # Should be slugified (no quotes, colons, etc.) 80 | expect(special_doc.path).not_to include('"') 81 | expect(special_doc.path).not_to include("'") 82 | expect(special_doc.path).not_to include(":") 83 | end 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /lib/jekyll-notion/cassette_manager.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "yaml" 4 | require "fileutils" 5 | 6 | module JekyllNotion 7 | class CassetteManager 8 | INDEX_BASENAME = ".pages_index.yml" 9 | PAGES_DIR = "pages" 10 | 11 | def initialize(cache_dir) 12 | @cache_dir = cache_dir 13 | end 14 | 15 | def cassette_name_for(id) 16 | sanitized_id = sanitize_id(id) 17 | 18 | # a) index mapping wins 19 | if (pretty = load_index_yaml[sanitized_id]) && File.exist?(cassette_path(pretty)) 20 | return pretty 21 | end 22 | 23 | # b) any existing "*-id.yml" (handles prior runs / title changes) 24 | if (found = find_existing_by_id(sanitized_id)) 25 | return found 26 | end 27 | 28 | # c) fallback to plain id (first run) 29 | "#{PAGES_DIR}/#{sanitized_id}" 30 | end 31 | 32 | def update_after_call(id, result) 33 | return unless (title = extract_title(result)).to_s != "" 34 | 35 | sanitized_id = sanitize_id(id) 36 | current_cassette = cassette_name_for(sanitized_id) 37 | pretty_name = "#{PAGES_DIR}/#{sanitize_title(title)}-#{sanitized_id}" 38 | 39 | rename_cassette_if_needed(:from => current_cassette, :to => pretty_name) 40 | update_index_yaml(:id => sanitized_id, :pretty => pretty_name) 41 | end 42 | 43 | private 44 | 45 | attr_reader :cache_dir 46 | 47 | def cassette_path(name) 48 | File.join(cache_dir, "#{name}.yml") 49 | end 50 | 51 | def find_existing_by_id(id) 52 | matches = Dir[File.join(cache_dir, "pages", "*-#{id}.yml")] 53 | return nil if matches.empty? 54 | 55 | File.join(PAGES_DIR, File.basename(matches.first, ".yml")) 56 | end 57 | 58 | def rename_cassette_if_needed(from:, to:) 59 | return if from == to 60 | 61 | src = cassette_path(from) 62 | dst = cassette_path(to) 63 | return unless File.exist?(src) 64 | return if File.exist?(dst) 65 | 66 | FileUtils.mkdir_p(File.dirname(dst)) 67 | FileUtils.mv(src, dst) 68 | rescue SystemCallError 69 | nil 70 | end 71 | 72 | def index_path 73 | File.join(cache_dir, INDEX_BASENAME) 74 | end 75 | 76 | def load_index_yaml 77 | return {} unless File.exist?(index_path) 78 | 79 | YAML.safe_load(File.read(index_path), :permitted_classes => [], :aliases => false) || {} 80 | rescue Psych::SyntaxError 81 | {} 82 | end 83 | 84 | def update_index_yaml(id:, pretty:) 85 | idx = load_index_yaml 86 | return if idx[id] == pretty 87 | 88 | FileUtils.mkdir_p(File.dirname(index_path)) 89 | tmp = "#{index_path}.tmp" 90 | idx[id] = pretty 91 | File.write(tmp, idx.to_yaml) 92 | FileUtils.mv(tmp, index_path) 93 | end 94 | 95 | def sanitize_title(str) 96 | Jekyll::Utils.slugify(str) 97 | end 98 | 99 | def sanitize_id(id) 100 | id.delete("-") 101 | end 102 | 103 | def extract_title(metadata) 104 | metadata.title 105 | end 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /spec/integration/databases/preserve_existing_files_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe "Databases: preserve existing files" do 6 | let(:config) do 7 | Jekyll.configuration( 8 | "source" => SOURCE_DIR_2, # Contains existing posts 9 | "destination" => DEST_DIR, 10 | "notion" => { 11 | "databases" => [{ 12 | "id" => "1ae33dd5f3314402948069517fa40ae2", 13 | }], 14 | } 15 | ) 16 | end 17 | 18 | let(:site) { Jekyll::Site.new(config) } 19 | 20 | before do 21 | VCR.use_cassette("notion_database") { site.process } 22 | end 23 | 24 | it "imports database and generates documents" do 25 | site.posts.each do |document| 26 | if document.title == "Page 1" 27 | expect(document.output).to eq("This post is a clone from the notion database
\n") 28 | elsif document.title == "My Post" 29 | expect(document.output).to eq("Wow, what an amazing article!
\n") 30 | else 31 | expect_to_match_document(document) 32 | end 33 | end 34 | end 35 | 36 | it_behaves_like "a jekyll collection with existing posts", "posts" 37 | 38 | it "preserves existing posts from filesystem" do 39 | # Files present in the source dir should be preserved 40 | existing_post_1 = site.posts.find { |p| p.path.end_with?("2022-01-01-my-post.md") } 41 | existing_post_2 = site.posts.find { |p| p.path.end_with?("2022-01-23-page-1.md") } 42 | 43 | expect(existing_post_1).to be_an_instance_of(Jekyll::Document) 44 | expect(existing_post_2).to be_an_instance_of(Jekyll::Document) 45 | end 46 | 47 | it "combines filesystem posts and Notion database entries" do 48 | # Should have both existing filesystem posts and imported Notion entries 49 | expect(site.posts.size).to be >= 8 # 7 from database + at least 1 from filesystem 50 | end 51 | 52 | it "does not create duplicate files for existing Notion entries" do 53 | # If a file with the same name exists, it should not create a duplicate 54 | # This tests the file_exists? check in the Collection generator 55 | 56 | # Count posts with similar names - shouldn't have exact duplicates 57 | post_paths = site.posts.map(&:path) 58 | unique_basenames = post_paths.map { |p| File.basename(p) }.uniq 59 | 60 | expect(unique_basenames.size).to eq(post_paths.size) 61 | end 62 | 63 | context "with custom collection" do 64 | let(:config) do 65 | Jekyll.configuration( 66 | "source" => SOURCE_DIR_2, 67 | "destination" => DEST_DIR, 68 | "collections" => { "articles" => { "output" => true } }, 69 | "notion" => { 70 | "databases" => [{ 71 | "id" => "1ae33dd5f3314402948069517fa40ae2", 72 | "collection" => "articles", 73 | }], 74 | } 75 | ) 76 | end 77 | 78 | it_behaves_like "a collection is renderded correctly", "articles" 79 | 80 | it "creates articles collection without affecting existing posts" do 81 | expect(site.collections["articles"].size).to be > 0 82 | expect(site.posts.size).to eq(2) # Only the existing filesystem posts 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /spec/integration/databases/duplicate_databases_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe "Databases: duplicate database handling" do 6 | let(:config) do 7 | Jekyll.configuration( 8 | "source" => SOURCE_DIR, 9 | "destination" => DEST_DIR, 10 | "notion" => { 11 | "databases" => [ 12 | { "id" => "1ae33dd5f3314402948069517fa40ae2" }, 13 | { "id" => "1ae33dd5f3314402948069517fa40ae2" }, # Duplicate ID 14 | { "id" => "1ae33dd5f3314402948069517fa40ae2" }, # Another duplicate 15 | ], 16 | } 17 | ) 18 | end 19 | 20 | let(:site) { Jekyll::Site.new(config) } 21 | 22 | before do 23 | allow(Jekyll.logger).to receive(:warn) 24 | VCR.use_cassette("notion_database") { site.process } 25 | end 26 | 27 | # NOTE: Currently the system doesn't prevent duplicate database processing 28 | # Unlike pages which have duplicate detection, databases can be processed multiple times 29 | # This test documents the current behavior 30 | 31 | it "processes all database configurations" do 32 | # Each database configuration is processed independently 33 | # This means the same database could be imported multiple times 34 | expect(site.posts.size).to be > 0 35 | end 36 | 37 | it "does not warn about duplicate databases" do 38 | # Unlike pages, databases don't currently have duplicate detection 39 | expect(Jekyll.logger).not_to have_received(:warn) 40 | .with("Jekyll Notion:", %r!Duplicate!) 41 | end 42 | 43 | context "with different configurations for same database" do 44 | let(:config) do 45 | Jekyll.configuration( 46 | "source" => SOURCE_DIR, 47 | "destination" => DEST_DIR, 48 | "collections" => { "articles" => { "output" => true } }, 49 | "notion" => { 50 | "databases" => [ 51 | { 52 | "id" => "1ae33dd5f3314402948069517fa40ae2", 53 | "filter" => { "property" => "Select", "select" => { "equals" => "select1" } }, 54 | }, 55 | { 56 | "id" => "1ae33dd5f3314402948069517fa40ae2", # Same ID 57 | "collection" => "articles", 58 | }, 59 | { 60 | "id" => "1ae33dd5f3314402948069517fa40ae2", # Same ID 61 | "data" => "database_content", 62 | }, 63 | ], 64 | } 65 | ) 66 | end 67 | 68 | it "processes same database with different configurations" do 69 | expect(site.posts.size).to be > 0 70 | expect(site.collections["articles"].size).to be > 0 71 | expect(site.data["database_content"]).to be_an(Array) 72 | end 73 | 74 | it "allows importing same database to different targets" do 75 | # This is a legitimate use case - importing same database to multiple collections/data 76 | post_titles = site.posts.map { |p| p.data["title"] } 77 | article_titles = site.collections["articles"].map { |p| p.data["title"] } 78 | data_titles = site.data["database_content"].map { |p| p["title"] } 79 | 80 | expect(post_titles).not_to be_empty 81 | expect(article_titles).not_to be_empty 82 | expect(data_titles).not_to be_empty 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /spec/integration/databases/filtered_database_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe "Databases: filtered and sorted import" do 6 | context "with filter configuration" do 7 | let(:filter) { { "property" => "Select", "select" => { "equals" => "select1" } } } 8 | let(:config) do 9 | Jekyll.configuration( 10 | "source" => SOURCE_DIR, 11 | "destination" => DEST_DIR, 12 | "notion" => { 13 | "databases" => [{ 14 | "id" => "1ae33dd5f3314402948069517fa40ae2", 15 | "filter" => filter, 16 | }], 17 | } 18 | ) 19 | end 20 | 21 | let(:site) { Jekyll::Site.new(config) } 22 | 23 | it_behaves_like "passes filter to notion client" 24 | it_behaves_like "filters posts correctly", 2 25 | 26 | context "when processing site" do 27 | before do 28 | VCR.use_cassette("notion_database") { site.process } 29 | end 30 | 31 | it_behaves_like "a collection is renderded correctly", "posts" 32 | it_behaves_like "a jekyll collection", "posts" 33 | end 34 | end 35 | 36 | context "with sort configuration" do 37 | let(:sorts) { [{ "timestamp" => "created_time", "direction" => "ascending" }] } 38 | let(:config) do 39 | Jekyll.configuration( 40 | "source" => SOURCE_DIR, 41 | "destination" => DEST_DIR, 42 | "notion" => { 43 | "databases" => [{ 44 | "id" => "1ae33dd5f3314402948069517fa40ae2", 45 | "sorts" => sorts, 46 | }], 47 | } 48 | ) 49 | end 50 | 51 | let(:site) { Jekyll::Site.new(config) } 52 | 53 | it_behaves_like "passes sorts to notion client" 54 | it_behaves_like "sorts posts by created_time ascending" 55 | 56 | context "with descending sort" do 57 | let(:sorts) { [{ "timestamp" => "created_time", "direction" => "descending" }] } 58 | 59 | it_behaves_like "sorts posts by created_time descending" 60 | end 61 | 62 | context "when processing site" do 63 | before do 64 | VCR.use_cassette("notion_database") { site.process } 65 | end 66 | 67 | it_behaves_like "a collection is renderded correctly", "posts" 68 | it_behaves_like "a jekyll collection", "posts" 69 | end 70 | end 71 | 72 | context "with both filter and sort configuration" do 73 | let(:filter) { { "property" => "Select", "select" => { "equals" => "select1" } } } 74 | let(:sorts) { [{ "timestamp" => "created_time", "direction" => "descending" }] } 75 | let(:config) do 76 | Jekyll.configuration( 77 | "source" => SOURCE_DIR, 78 | "destination" => DEST_DIR, 79 | "notion" => { 80 | "databases" => [{ 81 | "id" => "1ae33dd5f3314402948069517fa40ae2", 82 | "filter" => filter, 83 | "sorts" => sorts, 84 | }], 85 | } 86 | ) 87 | end 88 | 89 | let(:site) { Jekyll::Site.new(config) } 90 | 91 | it_behaves_like "passes filter and sorts to notion client" 92 | it_behaves_like "filters posts correctly", 2 93 | it_behaves_like "sorts posts by created_time descending" 94 | 95 | context "when processing site" do 96 | before do 97 | VCR.use_cassette("notion_database") { site.process } 98 | end 99 | 100 | it_behaves_like "a collection is renderded correctly", "posts" 101 | it_behaves_like "a jekyll collection", "posts" 102 | end 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /spec/support/collection_examples.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.shared_examples "a jekyll collection" do |collection_name| 4 | it "page is stored in destination directory" do 5 | expected_path = site.collections[collection_name].first.destination(".") 6 | expect(File).to exist(expected_path) 7 | end 8 | 9 | it "stores every page title in the collection" do 10 | site.collections[collection_name].each do |page| 11 | expect(["Page 1", "Page 2", "Page 3", "lists", "tables", 12 | "Title: with “double quotes” and ‘single quotes’ and :colons: but forget àccénts: àáâãäāăȧǎȁȃ", "A very long document",]).to be_include(page.title) 13 | end 14 | end 15 | end 16 | 17 | RSpec.shared_examples "a jekyll collection with existing posts" do |collection_name| 18 | it "page is stored in destination directory" do 19 | expected_path = site.collections[collection_name].first.destination(".") 20 | expect(File).to exist(expected_path) 21 | end 22 | 23 | it "stores every page title in the collection" do 24 | site.collections[collection_name].each do |page| 25 | expect(["Page 1", "Page 2", "Page 3", "lists", "tables", 26 | "Title: with “double quotes” and ‘single quotes’ and :colons: but forget àccénts: àáâãäāăȧǎȁȃ", "A very long document", 27 | "My Post",]).to be_include(page.title) 28 | end 29 | end 30 | end 31 | 32 | RSpec.shared_examples "passes filter to notion client" do 33 | it "passes filter configuration to Notion client" do 34 | expect_any_instance_of(Notion::Client).to receive(:database_query) 35 | .with(hash_including(:filter => filter)).and_call_original 36 | 37 | VCR.use_cassette("notion_database") { site.process } 38 | end 39 | end 40 | 41 | RSpec.shared_examples "passes sorts to notion client" do 42 | it "passes sorts configuration to Notion client" do 43 | expect_any_instance_of(Notion::Client).to receive(:database_query) 44 | .with(hash_including(:sorts => sorts)).and_call_original 45 | 46 | VCR.use_cassette("notion_database") { site.process } 47 | end 48 | end 49 | 50 | RSpec.shared_examples "passes filter and sorts to notion client" do 51 | it "passes both filter and sorts configuration to Notion client" do 52 | expect_any_instance_of(Notion::Client).to receive(:database_query) 53 | .with(hash_including(:filter => filter, :sorts => sorts)).and_call_original 54 | 55 | VCR.use_cassette("notion_database") { site.process } 56 | end 57 | end 58 | 59 | RSpec.shared_examples "filters posts correctly" do |expected_count| 60 | it "fetches the expected number of filtered pages" do 61 | VCR.use_cassette("notion_database") { site.process } 62 | 63 | expect(site.posts.size).to eq(expected_count) 64 | end 65 | end 66 | 67 | RSpec.shared_examples "sorts posts by created_time ascending" do 68 | it "sorts posts in ascending order by created_time" do 69 | VCR.use_cassette("notion_database") { site.process } 70 | 71 | # With ascending sort by created_time, posts should be ordered from oldest to newest 72 | created_times = site.posts.map { |post| post.data["created_time"] } 73 | 74 | expect(created_times).to eq(created_times.sort) 75 | end 76 | end 77 | 78 | RSpec.shared_examples "sorts posts by created_time descending" do 79 | it "sorts posts in descending order by created_time" do 80 | VCR.use_cassette("notion_database") { site.process } 81 | 82 | # With descending sort by created_time, posts should be ordered from newest to oldest 83 | created_times = site.posts.map { |post| post.data["created_time"] } 84 | 85 | expect(created_times).to eq(created_times.sort.reverse) 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /spec/unit/cacheable_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe JekyllNotion::Cacheable do 6 | let(:cache_dir) { Dir.mktmpdir("cacheable-unit") } 7 | let(:test_class) do 8 | Class.new do 9 | prepend JekyllNotion::Cacheable 10 | 11 | attr_reader :id 12 | 13 | def initialize(id) 14 | @id = id 15 | end 16 | 17 | def call 18 | "original result" 19 | end 20 | end 21 | end 22 | let(:instance) { test_class.new("test-123") } 23 | 24 | before do 25 | JekyllNotion::Cacheable.configure( 26 | :cache_dir => cache_dir, 27 | :cache_enabled => true 28 | ) 29 | end 30 | 31 | after do 32 | FileUtils.rm_rf(cache_dir) 33 | end 34 | 35 | describe ".configure" do 36 | it "sets cache directory and enabled status" do 37 | # Use a valid temp directory instead of an invalid path 38 | temp_dir = Dir.mktmpdir("test-cache") 39 | 40 | JekyllNotion::Cacheable.configure( 41 | :cache_dir => temp_dir, 42 | :cache_enabled => false 43 | ) 44 | 45 | # cache_dir now returns VCR.configuration.cassette_library_dir for consistency 46 | expect(JekyllNotion::Cacheable.cache_dir).to eq(VCR.configuration.cassette_library_dir) 47 | expect(JekyllNotion::Cacheable.enabled?).to be false 48 | 49 | FileUtils.rm_rf(temp_dir) 50 | end 51 | end 52 | 53 | describe ".cache_dir" do 54 | it "always returns VCR configuration directory for consistency" do 55 | # cache_dir always returns VCR.configuration.cassette_library_dir 56 | # to ensure consistency between CassetteManager and VCR 57 | expect(JekyllNotion::Cacheable.cache_dir).to eq(VCR.configuration.cassette_library_dir) 58 | end 59 | 60 | it "maintains consistency when VCR configuration changes" do 61 | # Configure with a valid temp directory 62 | temp_dir = Dir.mktmpdir("consistency-test") 63 | 64 | JekyllNotion::Cacheable.configure( 65 | :cache_dir => temp_dir, 66 | :cache_enabled => true 67 | ) 68 | 69 | # Both should be the same after configuration 70 | expect(JekyllNotion::Cacheable.cache_dir).to eq(VCR.configuration.cassette_library_dir) 71 | expect(JekyllNotion::Cacheable.cache_dir).to eq(temp_dir) 72 | 73 | FileUtils.rm_rf(temp_dir) 74 | end 75 | end 76 | 77 | describe "#call" do 78 | context "when caching is disabled" do 79 | before do 80 | JekyllNotion::Cacheable.configure( 81 | :cache_dir => cache_dir, 82 | :cache_enabled => false 83 | ) 84 | end 85 | 86 | it "calls super without caching logic" do 87 | expect(VCR).not_to receive(:use_cassette) 88 | 89 | instance.call 90 | 91 | expect(instance.call).to eq("original result") 92 | end 93 | end 94 | 95 | context "when caching is enabled" do 96 | before do 97 | cassette_manager = instance_double(JekyllNotion::CassetteManager, 98 | :cassette_name_for => "cassette_name", :update_after_call => "") 99 | 100 | allow(JekyllNotion::CassetteManager).to receive(:new).and_return(cassette_manager) 101 | allow(VCR).to receive(:use_cassette).and_yield 102 | 103 | JekyllNotion::Cacheable.configure( 104 | :cache_dir => cache_dir, 105 | :cache_enabled => true 106 | ) 107 | end 108 | 109 | it "calls super uses with caching mechanism" do 110 | result = instance.call 111 | 112 | expect(VCR).to have_received(:use_cassette).with( 113 | "cassette_name" 114 | ) 115 | expect(result).to eq("original result") 116 | end 117 | end 118 | end 119 | end 120 | -------------------------------------------------------------------------------- /spec/support/page_data_examples.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.shared_examples "a jekyll data object" do |data_name| 4 | it "stores id into the data object" do 5 | expect(site.data[data_name]).to include("id" => "9dc17c9c-9d2e-469d-bbf0-f9648f3288d3") 6 | end 7 | 8 | it "stores created_time into the data object" do 9 | expect(site.data[data_name]).to include("created_time" => "2022-01-23T12:31:00.000Z") 10 | end 11 | 12 | it "stores last_edited_time into the data object" do 13 | expect(site.data[data_name]).to include("last_edited_time" => "2025-09-04T17:24:00.000Z") 14 | end 15 | 16 | it "stores cover into the data object" do 17 | expect(site.data[data_name]).to include("cover" => "https://www.notion.so/images/page-cover/met_canaletto_1720.jpg") 18 | end 19 | 20 | it "stores icon into the data object" do 21 | expect(site.data[data_name]).to include("icon" => "💥") 22 | end 23 | 24 | it "stores archived into the data object" do 25 | expect(site.data[data_name]).to include("archived" => false) 26 | end 27 | 28 | it "stores multi_select into the data object" do 29 | expected_value = %w(mselect1 mselect2 mselect3) 30 | expect(site.data[data_name]).to include("multi_select" => expected_value) 31 | end 32 | 33 | it "stores select into the data object" do 34 | expect(site.data[data_name]).to include("select" => "select1") 35 | end 36 | 37 | it "stores people into the data object" do 38 | expect(site.data[data_name]).to include("person" => ["Enrique Moriarty"]) 39 | end 40 | 41 | it "stores number into the data object" do 42 | expect(site.data[data_name]).to include("numbers" => 12) 43 | end 44 | 45 | it "stores phone_number into the data object" do 46 | expect(site.data[data_name]).to include("phone" => "983788379") 47 | end 48 | 49 | it "stores files into the data object" do 50 | expect(site.data[data_name]).to include( 51 | "file" => array_including( 52 | start_with("https://prod-files-secure.s3.us-west-2.amazonaws.com/") 53 | ) 54 | ) 55 | end 56 | 57 | it "stores email into the data object" do 58 | expect(site.data[data_name]).to include("email" => "hola@test.com") 59 | end 60 | 61 | it "stores checkbox into the data object" do 62 | expect(site.data[data_name]).to include("checkbox" => "false") 63 | end 64 | 65 | it "stores title into the data object" do 66 | expect(site.data[data_name]).to include("title" => "Page 1") 67 | end 68 | 69 | it "stores date into the data object" do 70 | expect(site.data[data_name]).to include("date" => Time.parse("2021-12-30")) 71 | end 72 | 73 | it "contains the content property" do 74 | expect(site.data[data_name]).to have_key("content") 75 | end 76 | 77 | it "stores the page body into the content property" do 78 | expect(site.data[data_name]["content"]).to include("Lorem ipsum") 79 | end 80 | end 81 | 82 | RSpec.shared_examples "a jekyll data array" do |data_name| 83 | it "stores data as an array" do 84 | expect(site.data[data_name]).to be_an(Array) 85 | expect(site.data[data_name].size).to be > 0 86 | end 87 | 88 | it "contains entries with content property" do 89 | site.data[data_name].each do |entry| 90 | expect(entry).to have_key("content") 91 | expect(entry).to have_key("title") 92 | end 93 | end 94 | 95 | it "contains entries with common database properties" do 96 | site.data[data_name].each do |entry| 97 | expect(entry).to have_key("created_time") 98 | expect(entry).to have_key("last_edited_time") 99 | end 100 | end 101 | 102 | it "contains entries with various property types" do 103 | all_entries = site.data[data_name] 104 | property_types = all_entries.flat_map(&:keys).uniq 105 | 106 | # Database typically contains various property types 107 | expect(property_types).to include("title", "content") 108 | end 109 | end 110 | -------------------------------------------------------------------------------- /lib/jekyll-notion/generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module JekyllNotion 4 | class Generator < Jekyll::Generator 5 | attr_reader :current_page, :current_db 6 | 7 | def generate(site) 8 | @site = site 9 | 10 | return unless config? && notion_token? 11 | 12 | assert_configuration 13 | setup 14 | 15 | @notion_client = Notion::Client.new 16 | 17 | import_notion_databases 18 | import_notion_pages 19 | end 20 | 21 | def config 22 | @config ||= @site.config["notion"] || {} 23 | end 24 | 25 | def config_databases 26 | config["databases"] || [] 27 | end 28 | 29 | def config_pages 30 | config["pages"] || [] 31 | end 32 | 33 | protected 34 | 35 | def import_notion_databases 36 | config_databases.each do |db_config| 37 | next if db_config["id"].nil? 38 | 39 | notion_database = NotionToMd::Database.call(:id => db_config["id"], 40 | :notion_client => @notion_client, :filter => db_config["filter"], :sorts => db_config["sorts"], :frontmatter => true) 41 | Generators::Collection.call(:config => db_config, :site => @site, 42 | :notion_pages => notion_database.pages) 43 | end 44 | end 45 | 46 | def import_notion_pages 47 | config_pages.each do |page_config| 48 | next if page_config["id"].nil? 49 | 50 | notion_page = NotionToMd::Page.call(:id => page_config["id"], :notion_client => @notion_client, 51 | :frontmatter => true) 52 | Generators::Page.call(:config => page_config, :site => @site, 53 | :notion_pages => [notion_page]) 54 | end 55 | end 56 | 57 | def notion_token 58 | ENV.fetch("NOTION_TOKEN", nil) 59 | end 60 | 61 | def notion_token? 62 | if ENV["NOTION_TOKEN"].nil? || ENV["NOTION_TOKEN"].empty? 63 | Jekyll.logger.warn( 64 | "Jekyll Notion:", 65 | "Skipping import: NOTION_TOKEN is missing. Please set the NOTION_TOKEN environment variable to enable Notion integration." 66 | ) 67 | 68 | return false 69 | end 70 | true 71 | end 72 | 73 | def config? 74 | return false unless @site.config.key?("notion") 75 | 76 | if config.empty? || (config_databases.empty? && config_pages.empty?) 77 | Jekyll.logger.warn("Jekyll Notion:", 78 | "The `databases` or `pages` configuration are not declared. Skipping import.") 79 | return false 80 | end 81 | 82 | true 83 | end 84 | 85 | def setup 86 | # Cache Notion API responses 87 | JekyllNotion::Cacheable.configure( 88 | :cache_dir => config["cache_dir"], 89 | :cache_enabled => cache? 90 | ) 91 | end 92 | 93 | def cache? 94 | value = if config.key?("cache") 95 | config["cache"] 96 | else 97 | ENV.fetch("JEKYLL_NOTION_CACHE", nil) 98 | end 99 | value.nil? || !falsy?(value) 100 | end 101 | 102 | private 103 | 104 | def falsy?(value) 105 | %w(0 false no).include?(value.to_s.downcase) 106 | end 107 | 108 | def assert_configuration 109 | if config.key?("fetch_on_watch") 110 | Jekyll.logger.warn( 111 | "Jekyll Notion:", 112 | "The `fetch_on_watch` option was removed in v3. Please use the cache mechanism instead: https://github.com/emoriarty/jekyll-notion#cache" 113 | ) 114 | end 115 | 116 | if config.key?("database") 117 | Jekyll.logger.warn("Jekyll Notion:", 118 | "The `database` key is deprecated. Please use `databases` instead.") 119 | end 120 | 121 | if config["page"] 122 | Jekyll.logger.warn("Jekyll Notion:", 123 | "The `page` key is deprecated. Please use `pages` instead.") 124 | end 125 | end 126 | end 127 | end 128 | -------------------------------------------------------------------------------- /spec/unit/generators/collection_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe JekyllNotion::Generators::Collection do 6 | let(:site) do 7 | instance_double(Jekyll::Site, :in_source_dir => "/source/path", :collections => collections) 8 | end 9 | let(:collections) { { "posts" => instance_double(Jekyll::Collection, :docs => []) } } 10 | let(:config) { { "id" => "test-id" } } 11 | let(:notion_pages) { [] } 12 | let(:generator) do 13 | described_class.new(:config => config, :site => site, :notion_pages => notion_pages) 14 | end 15 | 16 | describe "#collection_name" do 17 | context "when collection is specified in config" do 18 | let(:config) { { "collection" => "articles" } } 19 | 20 | it "returns the specified collection name" do 21 | expect(generator.send(:collection_name)).to eq("articles") 22 | end 23 | end 24 | 25 | context "when collection is not specified in config" do 26 | let(:config) { {} } 27 | 28 | it "defaults to posts" do 29 | expect(generator.send(:collection_name)).to eq("posts") 30 | end 31 | end 32 | end 33 | 34 | describe "#make_filename" do 35 | let(:page) { double("NotionToMd::Page", :title => "Test Page Title") } 36 | 37 | context "with posts collection" do 38 | let(:config) { {} } # defaults to posts 39 | 40 | it "includes date prefix for posts" do 41 | allow(generator).to receive(:date_for).with(page).and_return(Date.parse("2023-01-15")) 42 | 43 | filename = generator.send(:make_filename, page) 44 | expect(filename).to eq("2023-01-15-test-page-title.md") 45 | end 46 | 47 | it "slugifies the title correctly" do 48 | allow(page).to receive(:title).and_return("Page with Special: Characters & Symbols!") 49 | allow(generator).to receive(:date_for).with(page).and_return(Date.parse("2023-01-15")) 50 | 51 | filename = generator.send(:make_filename, page) 52 | expect(filename).to match(%r!^2023-01-15-.+\.md$!) 53 | expect(filename).not_to include(":") 54 | expect(filename).not_to include("&") 55 | expect(filename).not_to include("!") 56 | end 57 | end 58 | 59 | context "with custom collection" do 60 | let(:config) { { "collection" => "articles" } } 61 | 62 | it "excludes date prefix for custom collections" do 63 | filename = generator.send(:make_filename, page) 64 | expect(filename).to eq("test-page-title.md") 65 | expect(filename).not_to match(%r!^\d{4}-\d{2}-\d{2}!) 66 | end 67 | 68 | it "slugifies the title correctly" do 69 | allow(page).to receive(:title).and_return("Article with Àccénts and Spåcês") 70 | 71 | filename = generator.send(:make_filename, page) 72 | expect(filename).to match(%r!^.+\.md$!) 73 | expect(filename).not_to include(" ") 74 | end 75 | end 76 | end 77 | 78 | describe "#make_path" do 79 | let(:page) { double("NotionToMd::Page", :title => "Test Page") } 80 | 81 | context "with posts collection" do 82 | let(:config) { {} } 83 | 84 | it "creates correct path for posts" do 85 | allow(generator).to receive(:make_filename).with(page).and_return("2023-01-15-test-page.md") 86 | 87 | path = generator.send(:make_path, page) 88 | expect(path).to eq("_posts/2023-01-15-test-page.md") 89 | end 90 | end 91 | 92 | context "with custom collection" do 93 | let(:config) { { "collection" => "articles" } } 94 | 95 | it "creates correct path for custom collection" do 96 | allow(generator).to receive(:make_filename).with(page).and_return("test-page.md") 97 | 98 | path = generator.send(:make_path, page) 99 | expect(path).to eq("_articles/test-page.md") 100 | end 101 | end 102 | end 103 | 104 | describe "#date_for" do 105 | context "when page has date property" do 106 | let(:page) { double("NotionToMd::Page", :props => { "date" => "2023-05-20" }) } 107 | 108 | it "returns parsed date from date property" do 109 | date = generator.send(:date_for, page) 110 | expect(date).to eq(Date.parse("2023-05-20")) 111 | end 112 | end 113 | 114 | context "when page has no date property but has created_time" do 115 | let(:page) do 116 | double("NotionToMd::Page", 117 | :props => {}, 118 | :created_time => "2023-04-15T10:30:00.000Z") 119 | end 120 | 121 | it "falls back to created_time" do 122 | date = generator.send(:date_for, page) 123 | expect(date).to eq(Date.parse("2023-04-15")) 124 | end 125 | end 126 | 127 | context "when date property is nil" do 128 | let(:page) do 129 | double("NotionToMd::Page", 130 | :props => { "date" => nil }, 131 | :created_time => "2023-04-15T10:30:00.000Z") 132 | end 133 | 134 | it "falls back to created_time" do 135 | date = generator.send(:date_for, page) 136 | expect(date).to eq(Date.parse("2023-04-15")) 137 | end 138 | end 139 | 140 | context "when date property is invalid" do 141 | let(:page) do 142 | double("NotionToMd::Page", 143 | :props => { "date" => "invalid-date" }, 144 | :created_time => "2023-04-15T10:30:00.000Z") 145 | end 146 | 147 | it "raises ArgumentError for invalid date" do 148 | # Current implementation doesn't catch ArgumentError, only TypeError and NoMethodError 149 | expect { generator.send(:date_for, page) }.to raise_error(ArgumentError) 150 | end 151 | end 152 | end 153 | end 154 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | jekyll-notion (3.0.0.beta1) 5 | jekyll (>= 3.7, < 5.0) 6 | notion-ruby-client (~> 1.2.0) 7 | notion_to_md (= 3.0.0.beta2) 8 | vcr (~> 6.3.1) 9 | zeitwerk (~> 2.6) 10 | 11 | GEM 12 | remote: https://rubygems.org/ 13 | specs: 14 | activesupport (7.1.5.2) 15 | base64 16 | benchmark (>= 0.3) 17 | bigdecimal 18 | concurrent-ruby (~> 1.0, >= 1.0.2) 19 | connection_pool (>= 2.2.5) 20 | drb 21 | i18n (>= 1.6, < 2) 22 | logger (>= 1.4.2) 23 | minitest (>= 5.1) 24 | mutex_m 25 | securerandom (>= 0.3) 26 | tzinfo (~> 2.0) 27 | addressable (2.8.7) 28 | public_suffix (>= 2.0.2, < 7.0) 29 | ast (2.4.3) 30 | base64 (0.3.0) 31 | benchmark (0.4.1) 32 | bigdecimal (3.3.1) 33 | colorator (1.1.0) 34 | concurrent-ruby (1.3.5) 35 | connection_pool (2.5.4) 36 | crack (1.0.1) 37 | bigdecimal 38 | rexml 39 | csv (3.3.5) 40 | diff-lcs (1.6.2) 41 | docile (1.4.1) 42 | drb (2.2.3) 43 | em-websocket (0.5.3) 44 | eventmachine (>= 0.12.9) 45 | http_parser.rb (~> 0) 46 | eventmachine (1.2.7) 47 | faraday (2.8.1) 48 | base64 49 | faraday-net_http (>= 2.0, < 3.1) 50 | ruby2_keywords (>= 0.0.4) 51 | faraday-mashify (1.0.0) 52 | faraday (~> 2.0) 53 | hashie 54 | faraday-multipart (1.1.1) 55 | multipart-post (~> 2.0) 56 | faraday-net_http (3.0.2) 57 | ffi (1.17.2) 58 | ffi (1.17.2-x86_64-darwin) 59 | forwardable-extended (2.6.0) 60 | google-protobuf (3.25.8) 61 | google-protobuf (3.25.8-x86_64-darwin) 62 | hashdiff (1.2.1) 63 | hashie (5.0.0) 64 | http_parser.rb (0.8.0) 65 | i18n (1.14.7) 66 | concurrent-ruby (~> 1.0) 67 | jekyll (4.4.1) 68 | addressable (~> 2.4) 69 | base64 (~> 0.2) 70 | colorator (~> 1.0) 71 | csv (~> 3.0) 72 | em-websocket (~> 0.5) 73 | i18n (~> 1.0) 74 | jekyll-sass-converter (>= 2.0, < 4.0) 75 | jekyll-watch (~> 2.0) 76 | json (~> 2.6) 77 | kramdown (~> 2.3, >= 2.3.1) 78 | kramdown-parser-gfm (~> 1.0) 79 | liquid (~> 4.0) 80 | mercenary (~> 0.3, >= 0.3.6) 81 | pathutil (~> 0.9) 82 | rouge (>= 3.0, < 5.0) 83 | safe_yaml (~> 1.0) 84 | terminal-table (>= 1.8, < 4.0) 85 | webrick (~> 1.7) 86 | jekyll-sass-converter (3.0.0) 87 | sass-embedded (~> 1.54) 88 | jekyll-watch (2.2.1) 89 | listen (~> 3.0) 90 | json (2.18.0) 91 | kramdown (2.5.1) 92 | rexml (>= 3.3.9) 93 | kramdown-parser-gfm (1.1.0) 94 | kramdown (~> 2.0) 95 | language_server-protocol (3.17.0.5) 96 | liquid (4.0.4) 97 | listen (3.9.0) 98 | rb-fsevent (~> 0.10, >= 0.10.3) 99 | rb-inotify (~> 0.9, >= 0.9.10) 100 | logger (1.7.0) 101 | mercenary (0.4.0) 102 | minitest (5.25.5) 103 | multipart-post (2.4.1) 104 | mutex_m (0.3.0) 105 | notion-ruby-client (1.2.2) 106 | faraday (>= 2.0) 107 | faraday-mashify (>= 0.1.1) 108 | faraday-multipart (>= 1.0.4) 109 | hashie (~> 5) 110 | notion_to_md (3.0.0.beta2) 111 | activesupport (~> 7) 112 | notion-ruby-client (~> 1) 113 | zeitwerk (~> 2.6) 114 | parallel (1.27.0) 115 | parser (3.3.9.0) 116 | ast (~> 2.4.1) 117 | racc 118 | pathutil (0.16.2) 119 | forwardable-extended (~> 2.6) 120 | prism (1.4.0) 121 | public_suffix (5.1.1) 122 | racc (1.8.1) 123 | rainbow (3.1.1) 124 | rake (13.3.0) 125 | rb-fsevent (0.11.2) 126 | rb-inotify (0.11.1) 127 | ffi (~> 1.0) 128 | regexp_parser (2.11.2) 129 | rexml (3.4.4) 130 | rouge (4.6.0) 131 | rspec (3.13.2) 132 | rspec-core (~> 3.13.0) 133 | rspec-expectations (~> 3.13.0) 134 | rspec-mocks (~> 3.13.0) 135 | rspec-core (3.13.6) 136 | rspec-support (~> 3.13.0) 137 | rspec-expectations (3.13.5) 138 | diff-lcs (>= 1.2.0, < 2.0) 139 | rspec-support (~> 3.13.0) 140 | rspec-mocks (3.13.6) 141 | diff-lcs (>= 1.2.0, < 2.0) 142 | rspec-support (~> 3.13.0) 143 | rspec-support (3.13.6) 144 | rubocop (1.57.2) 145 | json (~> 2.3) 146 | language_server-protocol (>= 3.17.0) 147 | parallel (~> 1.10) 148 | parser (>= 3.2.2.4) 149 | rainbow (>= 2.2.2, < 4.0) 150 | regexp_parser (>= 1.8, < 3.0) 151 | rexml (>= 3.2.5, < 4.0) 152 | rubocop-ast (>= 1.28.1, < 2.0) 153 | ruby-progressbar (~> 1.7) 154 | unicode-display_width (>= 2.4.0, < 3.0) 155 | rubocop-ast (1.46.0) 156 | parser (>= 3.3.7.2) 157 | prism (~> 1.4) 158 | rubocop-jekyll (0.14.0) 159 | rubocop (~> 1.57.0) 160 | rubocop-performance (~> 1.2) 161 | rubocop-performance (1.23.1) 162 | rubocop (>= 1.48.1, < 2.0) 163 | rubocop-ast (>= 1.31.1, < 2.0) 164 | ruby-progressbar (1.13.0) 165 | ruby2_keywords (0.0.5) 166 | safe_yaml (1.0.5) 167 | sass-embedded (1.63.6) 168 | google-protobuf (~> 3.23) 169 | rake (>= 13.0.0) 170 | sass-embedded (1.63.6-x86_64-darwin) 171 | google-protobuf (~> 3.23) 172 | securerandom (0.3.2) 173 | simplecov (0.22.0) 174 | docile (~> 1.1) 175 | simplecov-html (~> 0.11) 176 | simplecov_json_formatter (~> 0.1) 177 | simplecov-html (0.13.2) 178 | simplecov_json_formatter (0.1.4) 179 | terminal-table (3.0.2) 180 | unicode-display_width (>= 1.1.1, < 3) 181 | tzinfo (2.0.6) 182 | concurrent-ruby (~> 1.0) 183 | unicode-display_width (2.6.0) 184 | vcr (6.3.1) 185 | base64 186 | webmock (3.26.1) 187 | addressable (>= 2.8.0) 188 | crack (>= 0.3.2) 189 | hashdiff (>= 0.4.0, < 2.0.0) 190 | webrick (1.9.1) 191 | zeitwerk (2.6.18) 192 | 193 | PLATFORMS 194 | ruby 195 | x86_64-darwin-19 196 | 197 | DEPENDENCIES 198 | bundler (~> 2) 199 | jekyll-notion! 200 | json (>= 2.10) 201 | rspec (~> 3.0) 202 | rubocop-jekyll (~> 0.12) 203 | simplecov (~> 0.21) 204 | webmock (~> 3.25) 205 | 206 | BUNDLED WITH 207 | 2.4.20 208 | -------------------------------------------------------------------------------- /spec/unit/generators/data_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe JekyllNotion::Generators::Data do 6 | let(:site) do 7 | instance_double(Jekyll::Site, 8 | :data => site_data, 9 | :converters => converters) 10 | end 11 | let(:site_data) { {} } 12 | let(:converters) { [markdown_converter] } 13 | let(:markdown_converter) do 14 | instance_double("Jekyll::Converters::Markdown", 15 | :matches => matches_md, 16 | :convert => "converted content") 17 | end 18 | let(:matches_md) { true } 19 | let(:config) { { "data" => "test_data" } } 20 | let(:generator) do 21 | described_class.new(:config => config, :site => site, :notion_pages => notion_pages) 22 | end 23 | 24 | before do 25 | allow(Jekyll.logger).to receive(:info) 26 | allow(Jekyll.logger).to receive(:debug) 27 | end 28 | 29 | describe "#call" do 30 | context "with multiple pages" do 31 | let(:page1) do 32 | double("NotionToMd::Page", 33 | :frontmatter_properties => { "title" => "Page 1", "id" => "page1-id" }, 34 | :body => "# Page 1\n\nContent 1") 35 | end 36 | let(:page2) do 37 | double("NotionToMd::Page", 38 | :frontmatter_properties => { "title" => "Page 2", "id" => "page2-id" }, 39 | :body => "# Page 2\n\nContent 2") 40 | end 41 | let(:notion_pages) { [page1, page2] } 42 | 43 | it "stores pages as array in site data" do 44 | generator.call 45 | 46 | expect(site.data["test_data"]).to be_an(Array) 47 | expect(site.data["test_data"].size).to eq(2) 48 | end 49 | 50 | it "includes page properties and converted content" do 51 | generator.call 52 | 53 | first_entry = site.data["test_data"].first 54 | expect(first_entry["title"]).to eq("Page 1") 55 | expect(first_entry["id"]).to eq("page1-id") 56 | expect(first_entry["content"]).to eq("converted content") 57 | end 58 | 59 | it "logs each page import" do 60 | expect(Jekyll.logger).to receive(:info).with("Jekyll Notion:", "Page => Page 1") 61 | expect(Jekyll.logger).to receive(:info).with("Jekyll Notion:", "Page => Page 2") 62 | 63 | generator.call 64 | end 65 | end 66 | 67 | context "with single page" do 68 | let(:page) do 69 | double("NotionToMd::Page", 70 | :frontmatter_properties => { "title" => "Single Page", "id" => "single-id" }, 71 | :body => "# Single Page\n\nSingle content") 72 | end 73 | let(:notion_pages) { [page] } 74 | 75 | it "stores page as object (not array) in site data" do 76 | generator.call 77 | 78 | expect(site.data["test_data"]).to be_a(Hash) 79 | expect(site.data["test_data"]).not_to be_an(Array) 80 | end 81 | 82 | it "includes page properties and converted content" do 83 | generator.call 84 | 85 | expect(site.data["test_data"]["title"]).to eq("Single Page") 86 | expect(site.data["test_data"]["id"]).to eq("single-id") 87 | expect(site.data["test_data"]["content"]).to eq("converted content") 88 | end 89 | 90 | it "logs single page import" do 91 | expect(Jekyll.logger).to receive(:info).with("Jekyll Notion:", "Page => Single Page") 92 | 93 | generator.call 94 | end 95 | end 96 | end 97 | 98 | describe "#convert" do 99 | let(:notion_pages) { [] } 100 | let(:page) do 101 | instance_double("NotionToMd::Page", 102 | :body => "# Test\n\nContent", 103 | :title => "Test Page") 104 | end 105 | 106 | context "with successful conversion" do 107 | it "applies site converters to page body" do 108 | expect(markdown_converter).to receive(:convert).with("# Test\n\nContent").and_return("Content
") 109 | 110 | result = generator.send(:convert, page) 111 | expect(result).to eq("Content
") 112 | end 113 | 114 | it "applies converters in order" do 115 | # Mock the converters method to return a specific order 116 | ordered_converters = [markdown_converter] 117 | allow(generator).to receive(:converters).and_return(ordered_converters) 118 | 119 | expect(markdown_converter).to receive(:convert).with("# Test\n\nContent").and_return("final converted content") 120 | 121 | result = generator.send(:convert, page) 122 | expect(result).to eq("final converted content") 123 | end 124 | end 125 | 126 | context "with conversion error" do 127 | let(:conversion_error) { StandardError.new("Conversion failed") } 128 | 129 | before do 130 | allow(markdown_converter).to receive(:convert).and_raise(conversion_error) 131 | allow(Jekyll.logger).to receive(:error) 132 | end 133 | 134 | it "logs error with converter class and page title" do 135 | expect(Jekyll.logger).to receive(:error).with("Conversion error:", 136 | %r!encountered an error while.*Test Page!) 137 | expect(Jekyll.logger).to receive(:error).with("", "Conversion failed") 138 | 139 | expect { generator.send(:convert, page) }.to raise_error(conversion_error) 140 | end 141 | 142 | it "re-raises the original error" do 143 | allow(Jekyll.logger).to receive(:error) 144 | 145 | expect { generator.send(:convert, page) }.to raise_error(StandardError, "Conversion failed") 146 | end 147 | end 148 | end 149 | 150 | describe "#converters" do 151 | let(:notion_pages) { [] } 152 | let(:all_converters) do 153 | [ 154 | markdown_converter, 155 | instance_double("Jekyll::Converters::Sass", :matches => false), 156 | instance_double("Jekyll::Converters::CoffeeScript", :matches => false), 157 | ] 158 | end 159 | 160 | before do 161 | allow(site).to receive(:converters).and_return(all_converters) 162 | end 163 | 164 | it "selects only converters that match .md files" do 165 | allow(markdown_converter).to receive(:matches).with(".md").and_return(true) 166 | allow(all_converters[1]).to receive(:matches).with(".md").and_return(false) 167 | allow(all_converters[2]).to receive(:matches).with(".md").and_return(false) 168 | 169 | converters = generator.send(:converters) 170 | expect(converters).to eq([markdown_converter]) 171 | end 172 | 173 | it "sorts the selected converters" do 174 | selected_converters = [markdown_converter] 175 | allow(site.converters).to receive(:select).and_return(selected_converters) 176 | expect(selected_converters).to receive(:sort!).and_return(selected_converters) 177 | 178 | generator.send(:converters) 179 | end 180 | end 181 | end 182 | -------------------------------------------------------------------------------- /spec/unit/generators/generator_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe JekyllNotion::Generator do 6 | let(:site) { instance_double(Jekyll::Site, :config => site_config) } 7 | let(:site_config) { { "notion" => notion_config } } 8 | let(:notion_config) { {} } 9 | let(:generator) { described_class.new } 10 | 11 | before do 12 | generator.instance_variable_set(:@site, site) 13 | allow(ENV).to receive(:fetch).with("NOTION_TOKEN", nil).and_return("test-token") 14 | allow(Jekyll.logger).to receive(:warn) 15 | allow(Jekyll.logger).to receive(:info) 16 | end 17 | 18 | describe "#config_databases" do 19 | context "when databases are configured" do 20 | let(:notion_config) { { "databases" => [{ "id" => "db1" }, { "id" => "db2" }] } } 21 | 22 | it "returns the databases array" do 23 | expect(generator.send(:config_databases)).to eq([{ "id" => "db1" }, { "id" => "db2" }]) 24 | end 25 | end 26 | 27 | context "when databases are not configured" do 28 | let(:notion_config) { {} } 29 | 30 | it "returns empty array" do 31 | expect(generator.send(:config_databases)).to eq([]) 32 | end 33 | end 34 | end 35 | 36 | describe "#config_pages" do 37 | context "when pages are configured" do 38 | let(:notion_config) { { "pages" => [{ "id" => "page1" }, { "id" => "page2" }] } } 39 | 40 | it "returns the pages array" do 41 | expect(generator.send(:config_pages)).to eq([{ "id" => "page1" }, { "id" => "page2" }]) 42 | end 43 | end 44 | 45 | context "when pages are not configured" do 46 | let(:notion_config) { {} } 47 | 48 | it "returns empty array" do 49 | expect(generator.send(:config_pages)).to eq([]) 50 | end 51 | end 52 | end 53 | 54 | describe "#notion_token?" do 55 | context "when NOTION_TOKEN is set" do 56 | before { allow(ENV).to receive(:[]).with("NOTION_TOKEN").and_return("valid-token") } 57 | 58 | it "returns true" do 59 | expect(generator.send(:notion_token?)).to be true 60 | end 61 | end 62 | 63 | context "when NOTION_TOKEN is nil" do 64 | before { allow(ENV).to receive(:[]).with("NOTION_TOKEN").and_return(nil) } 65 | 66 | it "returns false and logs warning" do 67 | expect(Jekyll.logger).to receive(:warn).with("Jekyll Notion:", %r!NOTION_TOKEN is missing!) 68 | expect(generator.send(:notion_token?)).to be false 69 | end 70 | end 71 | 72 | context "when NOTION_TOKEN is empty" do 73 | before { allow(ENV).to receive(:[]).with("NOTION_TOKEN").and_return("") } 74 | 75 | it "returns false and logs warning" do 76 | expect(Jekyll.logger).to receive(:warn).with("Jekyll Notion:", %r!NOTION_TOKEN is missing!) 77 | expect(generator.send(:notion_token?)).to be false 78 | end 79 | end 80 | end 81 | 82 | describe "#config?" do 83 | context "when site has notion config key" do 84 | context "with valid databases config" do 85 | let(:notion_config) { { "databases" => [{ "id" => "db1" }] } } 86 | 87 | it "returns true" do 88 | expect(generator.send(:config?)).to be true 89 | end 90 | end 91 | 92 | context "with valid pages config" do 93 | let(:notion_config) { { "pages" => [{ "id" => "page1" }] } } 94 | 95 | it "returns true" do 96 | expect(generator.send(:config?)).to be true 97 | end 98 | end 99 | 100 | context "with both databases and pages" do 101 | let(:notion_config) do 102 | { 103 | "databases" => [{ "id" => "db1" }], 104 | "pages" => [{ "id" => "page1" }], 105 | } 106 | end 107 | 108 | it "returns true" do 109 | expect(generator.send(:config?)).to be true 110 | end 111 | end 112 | 113 | context "with empty config" do 114 | let(:notion_config) { {} } 115 | 116 | it "returns false and logs warning" do 117 | expect(Jekyll.logger).to receive(:warn).with("Jekyll Notion:", 118 | %r!databases.*or.*pages.*not declared!) 119 | expect(generator.send(:config?)).to be false 120 | end 121 | end 122 | 123 | context "with empty databases and pages arrays" do 124 | let(:notion_config) { { "databases" => [], "pages" => [] } } 125 | 126 | it "returns false and logs warning" do 127 | expect(Jekyll.logger).to receive(:warn).with("Jekyll Notion:", 128 | %r!databases.*or.*pages.*not declared!) 129 | expect(generator.send(:config?)).to be false 130 | end 131 | end 132 | end 133 | 134 | context "when site has no notion config key" do 135 | let(:site_config) { {} } 136 | 137 | it "returns false" do 138 | expect(generator.send(:config?)).to be false 139 | end 140 | end 141 | end 142 | 143 | describe "#cache?" do 144 | context "when cache is explicitly configured" do 145 | context "with cache: true" do 146 | let(:notion_config) { { "cache" => true } } 147 | 148 | it "returns true" do 149 | expect(generator.send(:cache?)).to be true 150 | end 151 | end 152 | 153 | context "with cache: false" do 154 | let(:notion_config) { { "cache" => false } } 155 | 156 | it "returns false" do 157 | expect(generator.send(:cache?)).to be false 158 | end 159 | end 160 | 161 | context "with cache: nil" do 162 | let(:notion_config) { { "cache" => nil } } 163 | 164 | it "returns true (nil is truthy for cache)" do 165 | expect(generator.send(:cache?)).to be true 166 | end 167 | end 168 | 169 | context "with cache: '0'" do 170 | let(:notion_config) { { "cache" => "0" } } 171 | 172 | it "returns false (0 is falsy for cache)" do 173 | expect(generator.send(:cache?)).to be false 174 | end 175 | end 176 | 177 | context "with cache: 'false'" do 178 | let(:notion_config) { { "cache" => "false" } } 179 | 180 | it "returns false" do 181 | expect(generator.send(:cache?)).to be false 182 | end 183 | end 184 | 185 | context "with cache: 'no'" do 186 | let(:notion_config) { { "cache" => "no" } } 187 | 188 | it "returns false" do 189 | expect(generator.send(:cache?)).to be false 190 | end 191 | end 192 | end 193 | 194 | context "when cache is not configured but ENV var is set" do 195 | let(:notion_config) { {} } 196 | 197 | context "with JEKYLL_NOTION_CACHE=1" do 198 | before { allow(ENV).to receive(:fetch).with("JEKYLL_NOTION_CACHE", nil).and_return("1") } 199 | 200 | it "returns true" do 201 | expect(generator.send(:cache?)).to be true 202 | end 203 | end 204 | 205 | context "with JEKYLL_NOTION_CACHE=0" do 206 | before { allow(ENV).to receive(:fetch).with("JEKYLL_NOTION_CACHE", nil).and_return("0") } 207 | 208 | it "returns false" do 209 | expect(generator.send(:cache?)).to be false 210 | end 211 | end 212 | 213 | context "with JEKYLL_NOTION_CACHE=false" do 214 | before do 215 | allow(ENV).to receive(:fetch).with("JEKYLL_NOTION_CACHE", nil).and_return("false") 216 | end 217 | 218 | it "returns false" do 219 | expect(generator.send(:cache?)).to be false 220 | end 221 | end 222 | 223 | context "with no ENV var" do 224 | before { allow(ENV).to receive(:fetch).with("JEKYLL_NOTION_CACHE", nil).and_return(nil) } 225 | 226 | it "returns true (default is true)" do 227 | expect(generator.send(:cache?)).to be true 228 | end 229 | end 230 | end 231 | end 232 | 233 | describe "#falsy?" do 234 | it "recognizes '0' as falsy" do 235 | expect(generator.send(:falsy?, "0")).to be true 236 | end 237 | 238 | it "recognizes 'false' as falsy" do 239 | expect(generator.send(:falsy?, "false")).to be true 240 | end 241 | 242 | it "recognizes 'no' as falsy" do 243 | expect(generator.send(:falsy?, "no")).to be true 244 | end 245 | 246 | it "recognizes 'FALSE' as falsy (case insensitive)" do 247 | expect(generator.send(:falsy?, "FALSE")).to be true 248 | end 249 | 250 | it "recognizes other values as truthy" do 251 | expect(generator.send(:falsy?, "1")).to be false 252 | expect(generator.send(:falsy?, "true")).to be false 253 | expect(generator.send(:falsy?, "yes")).to be false 254 | expect(generator.send(:falsy?, "anything")).to be false 255 | end 256 | 257 | it "handles non-string values" do 258 | expect(generator.send(:falsy?, 0)).to be true 259 | expect(generator.send(:falsy?, false)).to be true 260 | end 261 | end 262 | end 263 | -------------------------------------------------------------------------------- /spec/unit/cassette_manager_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe JekyllNotion::CassetteManager do 6 | let(:cache_dir) { Dir.mktmpdir("cassette-manager-unit") } 7 | let(:manager) { described_class.new(cache_dir) } 8 | let(:page_id) { "test-123" } 9 | 10 | after { FileUtils.rm_rf(cache_dir) } 11 | 12 | describe "#cassette_name_for" do 13 | context "when index mapping exists and file exists" do 14 | before do 15 | index_path = File.join(cache_dir, ".pages_index.yml") 16 | FileUtils.mkdir_p(File.dirname(index_path)) 17 | File.write(index_path, { "test123" => "pages/pretty-name-test123" }.to_yaml) 18 | 19 | pretty_file = File.join(cache_dir, "pages", "pretty-name-test123.yml") 20 | FileUtils.mkdir_p(File.dirname(pretty_file)) 21 | File.write(pretty_file, "test") 22 | end 23 | 24 | it "returns the pretty name from index" do 25 | result = manager.cassette_name_for(page_id) 26 | expect(result).to eq("pages/pretty-name-test123") 27 | end 28 | end 29 | 30 | context "when existing file matches ID pattern" do 31 | before do 32 | existing_file = File.join(cache_dir, "pages", "old-title-test123.yml") 33 | FileUtils.mkdir_p(File.dirname(existing_file)) 34 | File.write(existing_file, "test") 35 | end 36 | 37 | it "returns the existing filename" do 38 | result = manager.cassette_name_for(page_id) 39 | expect(result).to eq("pages/old-title-test123") 40 | end 41 | end 42 | 43 | context "when no existing files found" do 44 | it "returns plain ID fallback" do 45 | result = manager.cassette_name_for(page_id) 46 | expect(result).to eq("pages/test123") 47 | end 48 | end 49 | end 50 | 51 | describe "#update_after_call" do 52 | let(:result_with_title) do 53 | double("result", :title => "Test Page Title") 54 | end 55 | 56 | let(:result_without_title) do 57 | double("result", :title => "") 58 | end 59 | 60 | context "when result has a title" do 61 | it "updates index and renames cassette" do 62 | allow(manager).to receive(:rename_cassette_if_needed) 63 | allow(manager).to receive(:update_index_yaml) 64 | 65 | manager.update_after_call(page_id, result_with_title) 66 | 67 | expect(manager).to have_received(:rename_cassette_if_needed).with( 68 | :from => "pages/test123", 69 | :to => "pages/test-page-title-test123" 70 | ) 71 | expect(manager).to have_received(:update_index_yaml).with( 72 | :id => "test123", 73 | :pretty => "pages/test-page-title-test123" 74 | ) 75 | end 76 | end 77 | 78 | context "when result has no title" do 79 | it "does nothing" do 80 | allow(manager).to receive(:rename_cassette_if_needed) 81 | allow(manager).to receive(:update_index_yaml) 82 | 83 | manager.update_after_call(page_id, result_without_title) 84 | 85 | expect(manager).not_to have_received(:rename_cassette_if_needed) 86 | expect(manager).not_to have_received(:update_index_yaml) 87 | end 88 | end 89 | end 90 | 91 | describe "private methods" do 92 | describe "#find_existing_by_id" do 93 | context "when matching files exist" do 94 | before do 95 | existing_file = File.join(cache_dir, "pages", "some-title-test123.yml") 96 | FileUtils.mkdir_p(File.dirname(existing_file)) 97 | File.write(existing_file, "test") 98 | end 99 | 100 | it "returns the basename without extension" do 101 | result = manager.send(:find_existing_by_id, "test123") 102 | expect(result).to eq("pages/some-title-test123") 103 | end 104 | end 105 | 106 | context "when no matching files exist" do 107 | it "returns nil" do 108 | result = manager.send(:find_existing_by_id, "nonexistent") 109 | expect(result).to be_nil 110 | end 111 | end 112 | end 113 | 114 | describe "#rename_cassette_if_needed" do 115 | context "when source and destination are the same" do 116 | it "does nothing" do 117 | expect(FileUtils).not_to receive(:mv) 118 | manager.send(:rename_cassette_if_needed, :from => "pages/same", :to => "pages/same") 119 | end 120 | end 121 | 122 | context "when source file exists and destination doesn't" do 123 | before do 124 | src_file = File.join(cache_dir, "pages", "old-name.yml") 125 | FileUtils.mkdir_p(File.dirname(src_file)) 126 | File.write(src_file, "test content") 127 | end 128 | 129 | it "renames the file" do 130 | manager.send(:rename_cassette_if_needed, :from => "pages/old-name", 131 | :to => "pages/new-name") 132 | 133 | src_path = File.join(cache_dir, "pages", "old-name.yml") 134 | dst_path = File.join(cache_dir, "pages", "new-name.yml") 135 | 136 | expect(File.exist?(src_path)).to be false 137 | expect(File.exist?(dst_path)).to be true 138 | expect(File.read(dst_path)).to eq("test content") 139 | end 140 | end 141 | 142 | context "when source doesn't exist" do 143 | it "does nothing" do 144 | expect do 145 | manager.send(:rename_cassette_if_needed, :from => "pages/nonexistent", 146 | :to => "pages/new-name") 147 | end.not_to raise_error 148 | 149 | expect(File.exist?(File.join(cache_dir, "pages", "new-name.yml"))).to be false 150 | end 151 | end 152 | 153 | context "when destination already exists" do 154 | before do 155 | src_file = File.join(cache_dir, "pages", "old-name.yml") 156 | dst_file = File.join(cache_dir, "pages", "new-name.yml") 157 | FileUtils.mkdir_p(File.dirname(src_file)) 158 | File.write(src_file, "source") 159 | File.write(dst_file, "destination") 160 | end 161 | 162 | it "does nothing to avoid overwriting" do 163 | manager.send(:rename_cassette_if_needed, :from => "pages/old-name", 164 | :to => "pages/new-name") 165 | 166 | src_path = File.join(cache_dir, "pages", "old-name.yml") 167 | dst_path = File.join(cache_dir, "pages", "new-name.yml") 168 | 169 | expect(File.read(src_path)).to eq("source") 170 | expect(File.read(dst_path)).to eq("destination") 171 | end 172 | end 173 | end 174 | 175 | describe "#load_index_yaml" do 176 | context "when index file exists and is valid" do 177 | before do 178 | index_path = File.join(cache_dir, ".pages_index.yml") 179 | FileUtils.mkdir_p(File.dirname(index_path)) 180 | File.write(index_path, { "key" => "value" }.to_yaml) 181 | end 182 | 183 | it "returns the parsed YAML content" do 184 | result = manager.send(:load_index_yaml) 185 | expect(result).to eq({ "key" => "value" }) 186 | end 187 | end 188 | 189 | context "when index file doesn't exist" do 190 | it "returns empty hash" do 191 | result = manager.send(:load_index_yaml) 192 | expect(result).to eq({}) 193 | end 194 | end 195 | 196 | context "when index file has invalid YAML" do 197 | before do 198 | index_path = File.join(cache_dir, ".pages_index.yml") 199 | FileUtils.mkdir_p(File.dirname(index_path)) 200 | File.write(index_path, "invalid: yaml: content:\n - unclosed") 201 | end 202 | 203 | it "returns empty hash on syntax error" do 204 | result = manager.send(:load_index_yaml) 205 | expect(result).to eq({}) 206 | end 207 | end 208 | end 209 | 210 | describe "#update_index_yaml" do 211 | it "creates new index file with mapping" do 212 | manager.send(:update_index_yaml, :id => "page123", :pretty => "pages/nice-title") 213 | 214 | index_path = File.join(cache_dir, ".pages_index.yml") 215 | expect(File.exist?(index_path)).to be true 216 | 217 | content = YAML.safe_load(File.read(index_path)) 218 | expect(content["page123"]).to eq("pages/nice-title") 219 | end 220 | 221 | it "updates existing index file" do 222 | manager.send(:update_index_yaml, :id => "page1", :pretty => "pages/title1") 223 | manager.send(:update_index_yaml, :id => "new", :pretty => "pages/new-title") 224 | 225 | index_path = File.join(cache_dir, ".pages_index.yml") 226 | content = YAML.safe_load(File.read(index_path)) 227 | 228 | expect(content["page1"]).to eq("pages/title1") 229 | expect(content["new"]).to eq("pages/new-title") 230 | end 231 | 232 | it "doesn't update if mapping is unchanged" do 233 | manager.send(:update_index_yaml, :id => "page1", :pretty => "pages/same") 234 | 235 | index_path = File.join(cache_dir, ".pages_index.yml") 236 | original_mtime = File.mtime(index_path) 237 | 238 | sleep 0.01 239 | manager.send(:update_index_yaml, :id => "page1", :pretty => "pages/same") 240 | 241 | expect(File.mtime(index_path)).to eq(original_mtime) 242 | end 243 | end 244 | 245 | describe "utility methods" do 246 | describe "#sanitize_title" do 247 | it "uses Jekyll's slugify for title sanitization" do 248 | result = manager.send(:sanitize_title, "My Title!") 249 | expect(result).to eq("my-title") 250 | end 251 | end 252 | 253 | describe "#sanitize_id" do 254 | it "removes dashes from IDs" do 255 | result = manager.send(:sanitize_id, "abc-123-def") 256 | expect(result).to eq("abc123def") 257 | end 258 | end 259 | end 260 | end 261 | end 262 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |
3 | # jekyll-notion
4 |
5 | > [!WARNING]
6 | > The **main branch** is under active development for version 3.
7 | > For the current **stable release**, please check out the [v2.x.x branch](https://github.com/emoriarty/jekyll-notion/tree/v2.x.x).
8 |
9 | Import [Notion](https://www.notion.so) pages into
10 | [Jekyll](https://jekyllrb.com/).
11 |
12 | 📚 Learn more with these guides:
13 | - [Load Notion pages in
14 | Jekyll](https://enrq.me/dev/2022/03/20/load-notion-pages-in-jekyll/)
15 | - [Managing Jekyll posts in
16 | Notion](https://enrq.me/dev/2022/03/24/managing-jekyll-posts-in-notion/)
17 | - [Embedding videos with
18 | jekyll-notion](https://enrq.me/dev/2023/03/31/embedding-videos-with-jekyll-notion/)
19 |
20 | ## Installation
21 |
22 | Install via RubyGems:
23 |
24 | ``` bash
25 | gem install jekyll-notion
26 | ```
27 |
28 | Or add it to your `Gemfile`:
29 |
30 | ``` ruby
31 | # Gemfile
32 | gem 'jekyll-notion'
33 | ```
34 |
35 | > \[!IMPORTANT\]\
36 | > If you are using **jekyll-archives**, list `jekyll-notion` *before*
37 | > `jekyll-archives` in the Gemfile. Otherwise, imported pages will not
38 | > be picked up.\
39 | > See the discussion
40 | > [here](https://github.com/emoriarty/jekyll-notion/issues/95#issuecomment-2732112458).
41 |
42 | Then enable the plugin in `_config.yml`:
43 |
44 | ``` yaml
45 | plugins:
46 | - jekyll-notion
47 | ```
48 |
49 | ### Beta version
50 |
51 | Learn about the new changes in the following [post](https://enrq.me/dev/2025/09/09/jekyll-notion-notion-to-md-3-0-0-beta/).
52 |
53 | If you want to try the **beta release**, install with the `--pre` flag:
54 |
55 | ```bash
56 | gem install jekyll-notion --pre
57 | ```
58 |
59 | Or pin the beta in your Gemfile:
60 |
61 | ```ruby
62 | gem "jekyll-notion", "3.0.0.beta1"
63 | ```
64 |
65 | ⚠️ This version is under active development. For stable usage, prefer the latest `2.x.x` release.
66 |
67 |
68 | ## Usage
69 |
70 | Before using the gem, [create a Notion
71 | integration](https://developers.notion.com/docs/getting-started) and
72 | generate a secret token.
73 |
74 | Export the token as an environment variable:
75 |
76 | ``` bash
77 | export NOTION_TOKEN=
John Rambo having a coffee.
4 | 5 |Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla quis neque vel odio lobortis posuere nec sit amet erat. Morbi congue velit quis ante accumsan volutpat. Nam ornare enim eu metus consectetur facilisis. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque ut convallis erat, eget egestas magna. Nunc non orci at ante placerat pretium in id justo. Morbi a mattis lacus. Nulla tempus, massa a cursus porta, risus leo varius urna, euismod tristique ante metus vitae lacus.
8 | 9 |Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla quis neque vel odio lobortis posuere nec sit amet erat. Morbi congue velit quis ante accumsan volutpat. Nam ornare enim eu metus consectetur facilisis. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque ut convallis erat, eget egestas magna. Nunc non orci at ante placerat pretium in id justo. Morbi a mattis lacus. Nulla tempus, massa a cursus porta, risus leo varius urna, euismod tristique ante metus vitae lacus.
12 | 13 |Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla quis neque vel odio lobortis
16 | 17 |62 |64 | 65 |Blablabla. This is a very large quote. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla quis neque vel odio lobortis posuere nec sit amet erat. Morbi congue velit quis ante accumsan volutpat. Nam ornare enim eu metus consectetur facilisis. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque ut convallis erat, eget egestas magna. Nunc non orci at ante placerat pretium in id justo. Morbi a mattis lacus. Nulla tempus, massa a cursus porta, risus leo varius urna, euismod tristique ante metus vitae lacus.
63 |
68 |70 | 71 |💡 Callout. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla quis neque vel odio lobortis posuere nec sit amet erat. Morbi congue velit quis ante accumsan volutpat. Nam ornare enim eu metus consectetur facilisis. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque ut convallis erat, eget egestas magna. Nunc non orci at ante placerat pretium in id justo. Morbi a mattis lacus. Nulla tempus, massa a cursus porta, risus leo varius urna, euismod tristique ante metus vitae lacus.
69 |
This is an equation: $E=mc^2$.
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla quis neque vel odio lobortis posuere nec sit amet erat. Morbi congue velit quis ante accumsan volutpat. Nam ornare enim eu metus consectetur facilisis. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque ut convallis erat, eget egestas magna. Nunc non orci at ante placerat pretium in id justo. Morbi a mattis lacus. Nulla tempus, massa a cursus porta, risus leo varius urna, euismod tristique ante metus vitae lacus.
86 | 87 |function fn(a) {
90 | return a;
91 | }
92 | This is a plain text
95 | italic **bold **~~strike ~~inline-code underline
italic
112 | 113 |bold
114 | 115 |strike-trough
underline
118 | 119 |inline-code
| 127 | | Column 1 | 128 |Column 1 | 129 |
|---|---|---|
| Row 1 | 134 |ñaña | 135 |blabla | 136 |
| Row 2 | 139 |prupru | 140 |tinonino | 141 |
https://github.com/emoriarty/notion_to_md/pull/74
148 | 149 |sample caption
154 | 155 | 156 | 157 |https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf
160 | 161 |dummy caption
162 | 163 | 164 | 165 |internal video sample
170 | 171 |https://www.youtube.com/watch?v=V2PRhxphH2w
172 | 173 |external video sample
174 | 175 |