├── .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 | My site 6 | 7 | 8 | POST LAYOUT 9 | {{ content }} 10 | 11 | 12 | -------------------------------------------------------------------------------- /spec/fixtures/my_site_2/_layouts/post.html: -------------------------------------------------------------------------------- 1 | --- 2 | --- 3 | 4 | 5 | My site 6 | 7 | 8 | POST LAYOUT 9 | {{ content }} 10 | 11 | 12 | -------------------------------------------------------------------------------- /spec/support/skip_import.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.shared_examples "skips import" do 4 | before do 5 | allow(Notion::Client).to receive(:new) 6 | end 7 | 8 | it "does not instantiate Notion::Client" do 9 | expect(Notion::Client).not_to have_received(:new) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | gemspec 5 | 6 | group :development, :test do 7 | gem "bundler", "~> 2" 8 | gem "json", ">= 2.10" 9 | gem "rspec", "~> 3.0" 10 | gem "rubocop-jekyll", "~> 0.12" 11 | gem "simplecov", "~> 0.21" 12 | gem "webmock", "~> 3.25" 13 | end 14 | -------------------------------------------------------------------------------- /lib/jekyll-notion/document_without_a_file.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module JekyllNotion 4 | class DocumentWithoutAFile < Jekyll::Document 5 | def read_content(**_opts) 6 | if content =~ YAML_FRONT_MATTER_REGEXP 7 | self.content = Regexp.last_match.post_match 8 | data_file = SafeYAML.load(Regexp.last_match(1)) 9 | merge_data!(data_file, :source => "YAML front matter") if data_file 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/fixtures/golden/lists.html: -------------------------------------------------------------------------------- 1 | 19 | 20 |
    21 |
  1. blabla
  2. 22 |
  3. blabla 23 |
      24 |
    1. blabla
    2. 25 |
    3. blabla
    4. 26 |
    27 |
  4. 28 |
  5. blabla 29 |
      30 |
    1. blabla 31 |
        32 |
      1. blabla
      2. 33 |
      34 |
    2. 35 |
    36 |
  6. 37 |
38 | 39 | -------------------------------------------------------------------------------- /spec/fixtures/golden/2022-09-11-lists.html: -------------------------------------------------------------------------------- 1 | 19 | 20 |
    21 |
  1. blabla
  2. 22 |
  3. blabla 23 |
      24 |
    1. blabla
    2. 25 |
    3. blabla
    4. 26 |
    27 |
  4. 28 |
  5. blabla 29 |
      30 |
    1. blabla 31 |
        32 |
      1. blabla
      2. 33 |
      34 |
    2. 35 |
    36 |
  6. 37 |
38 | 39 | -------------------------------------------------------------------------------- /spec/fixtures/spec_cache/.pages_index.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 25adb135281c80828cb1dc59437ae243: pages/a-very-long-document-25adb135281c80828cb1dc59437ae243 3 | 9349e5108c0e4c0ea772b187d63ecfe1: pages/title-with-double-quotes-and-single-quotes-and-colons-but-forget-àccénts-àáâãäāăȧǎȁȃ-9349e5108c0e4c0ea772b187d63ecfe1 4 | 7a33528a8a384148bdb1ca5a62a0ce3c: pages/tables-7a33528a8a384148bdb1ca5a62a0ce3c 5 | d7cb295f5ae14900916f53fc7b8362af: pages/lists-d7cb295f5ae14900916f53fc7b8362af 6 | 0b8c4501209246c1b800529623746afc: pages/page-2-0b8c4501209246c1b800529623746afc 7 | 6c9343606ef64b12abb6bb9dc0d53622: pages/page-3-6c9343606ef64b12abb6bb9dc0d53622 8 | 9dc17c9c9d2e469dbbf0f9648f3288d3: pages/page-1-9dc17c9c9d2e469dbbf0f9648f3288d3 9 | -------------------------------------------------------------------------------- /spec/integration/pages/multiple_pages_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe "Pages: multiple pages import" do 6 | let(:config) do 7 | Jekyll.configuration( 8 | "source" => SOURCE_DIR, 9 | "destination" => DEST_DIR, 10 | "notion" => { 11 | "pages" => [ 12 | { "id" => "9dc17c9c-9d2e-469d-bbf0-f9648f3288d3" }, # Page 1 13 | { "id" => "0b8c4501209246c1b800529623746afc" }, # Page 2 14 | ], 15 | } 16 | ) 17 | end 18 | 19 | let(:site) { Jekyll::Site.new(config) } 20 | 21 | before do 22 | allow(NotionToMd::Page).to receive(:call).and_call_original 23 | 24 | site.process 25 | end 26 | 27 | it_behaves_like "a page is rendered correctly", "Page 1" 28 | it_behaves_like "a page is rendered correctly", "Page 2" 29 | end 30 | -------------------------------------------------------------------------------- /lib/jekyll-notion/generators/collectionable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module JekyllNotion 4 | module Generators 5 | module Collectionable 6 | def page_exists?(collection, notion_page) 7 | page_exists = collection.any? do |page| 8 | page.data["title"]&.downcase == notion_page.title.downcase 9 | end 10 | 11 | if page_exists 12 | Jekyll.logger.warn("Jekyll Notion:", 13 | "Page `#{notion_page.title}` exists — skipping Notion import.") 14 | end 15 | 16 | page_exists 17 | end 18 | 19 | def log_page(page) 20 | Jekyll.logger.info("Jekyll Notion:", "Page => #{page.data["title"]}") 21 | Jekyll.logger.info("", "URL => #{page.url}") 22 | Jekyll.logger.debug("", "Props => #{page.data.keys.inspect}") 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/jekyll-notion/generators/page.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module JekyllNotion 4 | module Generators 5 | class Page < Generator 6 | include Collectionable 7 | 8 | def call 9 | if config["data"].nil? 10 | notion_pages.each { |notion_page| generate_page(notion_page) } 11 | else 12 | Data.call(:config => config, :site => site, 13 | :notion_pages => notion_pages) 14 | end 15 | end 16 | 17 | private 18 | 19 | def generate_page(notion_page) 20 | return if page_exists?(site.pages, notion_page) 21 | 22 | page = make_page(notion_page) 23 | 24 | site.pages << page 25 | 26 | log_page(page) 27 | end 28 | 29 | def make_page(notion_page) 30 | JekyllNotion::PageWithoutAFile.new(@site, @site.source, "", "#{notion_page.title}.md", 31 | notion_page.to_md) 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/jekyll-notion/page_without_a_file.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module JekyllNotion 4 | class PageWithoutAFile < Jekyll::Page 5 | def initialize(site, base, dir, name, new_content) 6 | self.content = new_content 7 | super(site, base, dir, name) 8 | end 9 | 10 | def read_yaml(base, name, _opts = {}) 11 | filename = @path || site.in_source_dir(base, name) 12 | Jekyll.logger.debug "Reading:", relative_path 13 | 14 | begin 15 | if content =~ Jekyll::Document::YAML_FRONT_MATTER_REGEXP 16 | self.content = Regexp.last_match.post_match 17 | self.data = SafeYAML.load(Regexp.last_match(1)) 18 | end 19 | rescue Psych::SyntaxError => e 20 | Jekyll.logger.warn "YAML Exception reading page #{name}: #{e.message}" 21 | raise e if site.config["strict_front_matter"] 22 | end 23 | 24 | self.data ||= {} 25 | 26 | validate_data! filename 27 | validate_permalink! filename 28 | 29 | self.data 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /jekyll-notion.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/jekyll-notion/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "jekyll-notion" 7 | spec.version = JekyllNotion::VERSION 8 | spec.authors = ["Enrique Arias"] 9 | spec.email = ["emoriarty81@gmail.com"] 10 | spec.summary = "A Jekyll plugin to generate pages from Notion" 11 | spec.homepage = "https://github.com/emoriarty/jekyll-notion" 12 | spec.license = "MIT" 13 | 14 | spec.files = Dir["lib/**/*", "README.md"] 15 | spec.extra_rdoc_files = Dir["README.md", "LICENSE.txt"] 16 | # spec.test_files = spec.files.grep(%r!^spec/!) 17 | spec.require_paths = ["lib"] 18 | 19 | spec.required_ruby_version = ">= 2.7.0" 20 | 21 | spec.add_dependency "jekyll", ">= 3.7", "< 5.0" 22 | spec.add_dependency "notion-ruby-client", "~> 1.2.0" 23 | spec.add_dependency "notion_to_md", "3.0.0.beta2" 24 | spec.add_dependency "vcr", "~> 6.3.1" 25 | spec.add_dependency "zeitwerk", "~> 2.6" 26 | end 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Enrique Arias 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/jekyll-notion.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "jekyll" 4 | require "notion" 5 | require "notion_to_md" 6 | require "logger" 7 | require "jekyll-notion/generator" 8 | require "vcr" 9 | 10 | NotionToMd::Logger.level = Logger::ERROR 11 | 12 | Notion.configure do |config| 13 | config.token = ENV.fetch("NOTION_TOKEN", nil) 14 | end 15 | 16 | module JekyllNotion 17 | autoload :DocumentWithoutAFile, "jekyll-notion/document_without_a_file" 18 | autoload :PageWithoutAFile, "jekyll-notion/page_without_a_file" 19 | autoload :Cacheable, "jekyll-notion/cacheable" 20 | autoload :CassetteManager, "jekyll-notion/cassette_manager" 21 | 22 | module Generators 23 | autoload :Generator, "jekyll-notion/generators/generator" 24 | autoload :Collectionable, "jekyll-notion/generators/collectionable" 25 | autoload :Data, "jekyll-notion/generators/data" 26 | autoload :Page, "jekyll-notion/generators/page" 27 | autoload :Collection, "jekyll-notion/generators/collection" 28 | end 29 | end 30 | 31 | # Prepend Cacheable module to NotionToMd::Page for instance method caching 32 | NotionToMd::Page.prepend(JekyllNotion::Cacheable) 33 | -------------------------------------------------------------------------------- /spec/integration/pages/single_page_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe "Pages: single page import" do 6 | let(:config) do 7 | Jekyll.configuration( 8 | "source" => SOURCE_DIR, 9 | "destination" => DEST_DIR, 10 | "notion" => { 11 | "pages" => [{ "id" => "9dc17c9c-9d2e-469d-bbf0-f9648f3288d3" }], # Page 1 12 | } 13 | ) 14 | end 15 | 16 | let(:site) { Jekyll::Site.new(config) } 17 | 18 | before do 19 | allow(NotionToMd::Page).to receive(:call).and_call_original 20 | 21 | site.process 22 | end 23 | 24 | it_behaves_like "a page is rendered correctly", "Page 1" 25 | it_behaves_like "a jekyll page", "Page 1" 26 | 27 | context "with page imported as data" do 28 | let(:config) do 29 | Jekyll.configuration( 30 | "source" => SOURCE_DIR, 31 | "destination" => DEST_DIR, 32 | "notion" => { 33 | "pages" => [ 34 | { 35 | "id" => "9dc17c9c-9d2e-469d-bbf0-f9648f3288d3", # Page 1 36 | "data" => "dummy", 37 | }, 38 | ], 39 | } 40 | ) 41 | end 42 | 43 | it_behaves_like "a jekyll data object", "dummy" 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /spec/support/golden_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # spec/support/golden_helper.rb 4 | module GoldenHelper 5 | GOLDEN_DIR = File.expand_path("../fixtures/golden", __dir__) 6 | 7 | def expect_to_match_golden_file(actual_content_or_path, golden_name) 8 | actual_content = 9 | if actual_content_or_path.is_a?(String) && File.file?(actual_content_or_path) 10 | File.read(actual_content_or_path) 11 | else 12 | actual_content_or_path # already content 13 | end 14 | 15 | golden_path = File.join("spec/fixtures/golden", golden_name) 16 | 17 | if ENV["UPDATE_GOLDEN"] 18 | File.write(golden_path, actual_content) 19 | warn "🔄 Updated golden file: #{golden_path}" 20 | end 21 | 22 | expect(actual_content).to eq(File.read(golden_path)) 23 | end 24 | 25 | # @param document => JekyllNotion::DocumentWithoutAFile 26 | def expect_to_match_document(document) 27 | golden_name = "#{document.basename_without_ext}#{document.output_ext}" 28 | 29 | expect_to_match_golden_file(document.output, golden_name) 30 | end 31 | 32 | # @param document => JekyllNotion::PageWithoutAFile 33 | def expect_to_match_page(page) 34 | golden_name = "#{page.basename}#{page.output_ext}" 35 | 36 | expect_to_match_golden_file(page.output, golden_name) 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /spec/integration/setup/missing_token_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe "Setup: missing NOTION_TOKEN" do 6 | let(:notion_token) { nil } 7 | let(:site) { Jekyll::Site.new(config) } 8 | let(:source_dir) { SOURCE_DIR } 9 | let(:dest_dir) { DEST_DIR } 10 | let(:config) do 11 | Jekyll.configuration( 12 | "source" => source_dir, 13 | "destination" => dest_dir, 14 | "notion" => { 15 | "pages" => [{ "id" => "9dc17c9c-9d2e-469d-bbf0-f9648f3288d3" }], # Page 1 16 | } 17 | ) 18 | end 19 | 20 | before do 21 | allow(Jekyll.logger).to receive(:warn) 22 | allow(ENV).to receive(:[]).with("NOTION_TOKEN").and_return(notion_token) 23 | 24 | site.process 25 | end 26 | 27 | it_behaves_like "skips import" 28 | 29 | it "logs an error when NOTION_TOKEN is nil" do 30 | expect(Jekyll.logger).to have_received(:warn).with( 31 | a_string_matching(%r!Jekyll Notion!i), 32 | a_string_matching(%r!skipping import: NOTION_TOKEN is missing!i) 33 | ) 34 | end 35 | 36 | context "when NOTION_TOKEN is empty" do 37 | let(:notion_token) { "" } 38 | 39 | it "logs an error when NOTION_TOKEN is empty" do 40 | expect(Jekyll.logger).to have_received(:warn).with( 41 | a_string_matching(%r!Jekyll Notion!i), 42 | a_string_matching(%r!skipping import: NOTION_TOKEN is missing!i) 43 | ) 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /spec/support/golden_examples.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.shared_examples "a page is rendered correctly" do |page_name| 4 | it "imports and generates page" do 5 | page = site.pages.find { |p| p.basename == page_name } 6 | 7 | raise "`#{page_name}` not found" if page.nil? 8 | 9 | expect_to_match_page(page) 10 | end 11 | end 12 | 13 | RSpec.shared_examples "a page is not rendered" do |page_name| 14 | it "imports and generates page" do 15 | page = site.pages.find { |p| p.basename == page_name } 16 | 17 | expect(page).to be_nil 18 | end 19 | end 20 | 21 | RSpec.shared_examples "all pages are renderer correctly" do 22 | it "imports and generates pages" do 23 | raise "The `pages` collection is empty" if site.pages.empty? 24 | 25 | site.pages.each { |page| expect_to_match_page(page) } 26 | end 27 | end 28 | 29 | RSpec.shared_examples "a collection is renderded correctly" do |collection_name| 30 | it "imports database and generates documents" do 31 | raise "The `#{collection_name}` collection is empty" if site.collections[collection_name].empty? 32 | 33 | site.collections[collection_name].each do |document| 34 | expect_to_match_document(document) 35 | end 36 | end 37 | end 38 | 39 | RSpec.shared_examples "a collection is not renderded" do |collection_name| 40 | it "imports database and generates documents" do 41 | expect(site.send(collection_name.to_sym)).to be_empty 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | /.config 4 | /coverage/ 5 | /InstalledFiles 6 | /pkg/ 7 | /spec/reports/ 8 | /spec/examples.txt 9 | /test/tmp/ 10 | /test/version_tmp/ 11 | /tmp/ 12 | 13 | # Used by dotenv library to load environment variables. 14 | .env 15 | .envrc 16 | 17 | # Ignore Byebug command history file. 18 | .byebug_history 19 | 20 | ## Specific to RubyMotion: 21 | .dat* 22 | .repl_history 23 | build/ 24 | *.bridgesupport 25 | build-iPhoneOS/ 26 | build-iPhoneSimulator/ 27 | 28 | ## Specific to RubyMotion (use of CocoaPods): 29 | # 30 | # We recommend against adding the Pods directory to your .gitignore. However 31 | # you should judge for yourself, the pros and cons are mentioned at: 32 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 33 | # 34 | # vendor/Pods/ 35 | 36 | ## Documentation cache and generated files: 37 | /.yardoc/ 38 | /_yardoc/ 39 | /doc/ 40 | /rdoc/ 41 | 42 | ## Environment normalization: 43 | /.bundle/ 44 | /vendor/bundle 45 | /lib/bundler/man/ 46 | 47 | # for a library or gem, you might want to ignore these files since the code is 48 | # intended to run in multiple environments; otherwise, check them in: 49 | # Gemfile.lock 50 | # .ruby-version 51 | # .ruby-gemset 52 | 53 | # unless supporting rvm < 1.11.0 or doing something fancy, ignore this: 54 | .rvmrc 55 | 56 | # Used by RuboCop. Remote config files pulled in from inherit_from directive. 57 | # .rubocop-https?--* 58 | 59 | .jekyll-cache 60 | -------------------------------------------------------------------------------- /.github/workflows/ruby.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | # This workflow will download a prebuilt Ruby version, install dependencies and run tests with Rake 6 | # For more information see: https://github.com/marketplace/actions/setup-ruby-jruby-and-truffleruby 7 | 8 | name: Ruby 9 | 10 | on: 11 | push: 12 | branches: [ "main", "v2.x.x" ] 13 | pull_request: 14 | branches: [ "main" ] 15 | 16 | permissions: 17 | contents: read 18 | 19 | jobs: 20 | test: 21 | 22 | runs-on: ubuntu-latest 23 | env: 24 | RUBYOPT: -ruri # ensure URI is defined to prevent NameError: uninitialized constant FileUtils::URI 25 | strategy: 26 | matrix: 27 | ruby-version: ['2.7', '3.0', '3.1', '3.2', '3.3', '3.4'] 28 | 29 | steps: 30 | - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 31 | - name: Set up Ruby 32 | uses: ruby/setup-ruby@v1 33 | with: 34 | ruby-version: ${{ matrix.ruby-version }} 35 | bundler-cache: true # runs 'bundle install' and caches installed gems automatically 36 | rubygems: latest 37 | bundler: latest 38 | - name: Run tests 39 | run: NOTION_TOKEN=dummy_secret_token bundle exec rspec 40 | - name: Archive code coverage results 41 | uses: actions/upload-artifact@v4 42 | with: 43 | name: code-coverage-report-${{ matrix.ruby-version }} 44 | path: coverage-${{ matrix.ruby-version }} 45 | -------------------------------------------------------------------------------- /lib/jekyll-notion/generators/data.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module JekyllNotion 4 | module Generators 5 | class Data < Generator 6 | # pages => Array of NotionToMd::Page 7 | def call 8 | data = if notion_pages.size > 1 9 | notion_pages.map do |page| 10 | page.send(:frontmatter_properties).merge({ "content" => convert(page) }) 11 | end 12 | else 13 | notion_pages.first.send(:frontmatter_properties).merge({ "content" => convert(notion_pages.first) }) 14 | end 15 | 16 | @site.data[config["data"]] = data 17 | 18 | log_data(data) 19 | end 20 | 21 | protected 22 | 23 | # Convert the notion page body using the site.converters. 24 | # 25 | # Returns String the converted content. 26 | def convert(page) 27 | converters.reduce(page.body) do |output, converter| 28 | converter.convert(output) 29 | rescue StandardError => e 30 | Jekyll.logger.error "Conversion error:", 31 | "#{converter.class} encountered an error while " \ 32 | "converting notion page '#{page.title}':" 33 | Jekyll.logger.error("", e.to_s) 34 | raise e 35 | end 36 | end 37 | 38 | def converters 39 | @converters ||= @site.converters.select { |c| c.matches(".md") }.tap(&:sort!) 40 | end 41 | 42 | def log_data(data) 43 | if data.is_a?(Array) 44 | data.each { |page| _log_data(page, Array.to_s) } 45 | else 46 | _log_data(data, Hash.to_s) 47 | end 48 | end 49 | 50 | def _log_data(page, type) 51 | Jekyll.logger.info("Jekyll Notion:", "Page => #{page["title"]}") 52 | Jekyll.logger.info("", "#{type} => site.data.#{config["data"]}") 53 | Jekyll.logger.debug("", "Props => #{page.keys.inspect}") 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/jekyll-notion/generators/generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module JekyllNotion 4 | module Generators 5 | # Abstract base class for Notion content generators. 6 | # 7 | # This class provides a common interface and factory method for creating 8 | # Jekyll content from Notion pages. Subclasses must implement the {#call} 9 | # method to define their specific generation behavior. 10 | # 11 | # @abstract Subclass and override {#call} to implement content generation logic 12 | class Generator 13 | class << self 14 | # Factory method to create and execute a generator instance. 15 | # 16 | # @param config [Hash] Configuration hash for the generator 17 | # @param site [Jekyll::Site] The Jekyll site instance 18 | # @param notion_pages [Array] Array of Notion pages to process 19 | # @return [void] 20 | def call(config:, site:, notion_pages:) 21 | new(:config => config, :site => site, 22 | :notion_pages => notion_pages).call 23 | end 24 | end 25 | 26 | # Initialize a new generator instance. 27 | # 28 | # @param config [Hash] Configuration hash for the generator 29 | # @param site [Jekyll::Site] The Jekyll site instance 30 | # @param notion_pages [Array] Array of Notion pages to process 31 | def initialize(config:, site:, notion_pages:) 32 | @notion_pages = notion_pages 33 | @config = config 34 | @site = site 35 | end 36 | 37 | attr_reader :config, :notion_pages, :site 38 | 39 | # Generate Jekyll content from Notion pages. 40 | # 41 | # @abstract Subclasses must implement this method to define their 42 | # specific content generation logic. 43 | # @raise [NotImplementedError] if called on the abstract base class 44 | # @return [void] 45 | def call 46 | raise NotImplementedError, "Subclasses must implement #call" 47 | end 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/jekyll-notion/cacheable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "cassette_manager" 4 | 5 | module JekyllNotion 6 | module Cacheable 7 | class << self 8 | def configure(cache_dir:, cache_enabled:) 9 | @cache_dir = cache_dir 10 | @cache_enabled = cache_enabled 11 | 12 | configure_vcr 13 | end 14 | 15 | def cache_dir 16 | # Always return VCR's configured directory to ensure consistency 17 | # between CassetteManager operations and VCR cassette storage 18 | VCR.configuration.cassette_library_dir 19 | end 20 | 21 | def enabled? 22 | @cache_enabled 23 | end 24 | 25 | private 26 | 27 | def configure_vcr 28 | # Determine the directory to use based on configuration and environment 29 | target_dir = @cache_dir || ENV["JEKYLL_NOTION_CACHE_DIR"] || File.join(Dir.pwd, ".cache", 30 | "jekyll-notion", "vcr_cassettes") 31 | 32 | VCR.configure do |config| 33 | config.cassette_library_dir = target_dir 34 | config.hook_into :faraday # Faraday is used by notion-ruby-client gem 35 | config.filter_sensitive_data("") { ENV.fetch("NOTION_TOKEN", nil) } 36 | config.allow_http_connections_when_no_cassette = true 37 | config.default_cassette_options = { 38 | :allow_playback_repeats => true, 39 | :record => :new_episodes, 40 | } 41 | end 42 | end 43 | end 44 | 45 | def call 46 | return super unless JekyllNotion::Cacheable.enabled? 47 | 48 | cassette_manager = CassetteManager.new(JekyllNotion::Cacheable.cache_dir) 49 | cassette_name = cassette_manager.cassette_name_for(id) 50 | result = nil 51 | 52 | VCR.use_cassette(cassette_name) do 53 | result = super 54 | end 55 | 56 | cassette_manager.update_after_call(id, result) 57 | result 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /.rubocop_todo.yml: -------------------------------------------------------------------------------- 1 | # This configuration was generated by 2 | # `rubocop --auto-gen-config` 3 | # on 2025-09-08 13:30:26 UTC using RuboCop version 1.57.2. 4 | # The point is for the user to remove these configuration records 5 | # one by one as the offenses are removed from the code base. 6 | # Note that changes in the inspected code, or installation of new 7 | # versions of RuboCop, may require this file to be generated again. 8 | 9 | # Offense count: 17 10 | # This cop supports safe autocorrection (--autocorrect). 11 | # Configuration parameters: AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, AllowedPatterns. 12 | # URISchemes: http, https 13 | Layout/LineLength: 14 | Max: 169 15 | 16 | # Offense count: 3 17 | # Configuration parameters: AllowedMethods. 18 | # AllowedMethods: enums 19 | Lint/ConstantDefinitionInBlock: 20 | Exclude: 21 | - 'spec/spec_helper.rb' 22 | 23 | # Offense count: 2 24 | # Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes. 25 | Metrics/AbcSize: 26 | Max: 26 27 | 28 | # Offense count: 1 29 | # Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns. 30 | Metrics/MethodLength: 31 | Max: 26 32 | 33 | # Offense count: 1 34 | # Configuration parameters: CountKeywordArgs, MaxOptionalParameters. 35 | Metrics/ParameterLists: 36 | Max: 5 37 | 38 | # Offense count: 1 39 | # Configuration parameters: AllowedMethods, AllowedPatterns. 40 | Metrics/PerceivedComplexity: 41 | Max: 10 42 | 43 | # Offense count: 2 44 | # Configuration parameters: EnforcedStyle, CheckMethodNames, CheckSymbols, AllowedIdentifiers, AllowedPatterns. 45 | # SupportedStyles: snake_case, normalcase, non_integer 46 | # AllowedIdentifiers: capture3, iso8601, rfc1123_date, rfc822, rfc2822, rfc3339, x86_64 47 | Naming/VariableNumber: 48 | Exclude: 49 | - 'spec/integration/databases/preserve_existing_files_spec.rb' 50 | 51 | # Offense count: 4 52 | Performance/ChainArrayAllocation: 53 | Exclude: 54 | - 'spec/integration/databases/multiple_databases_spec.rb' 55 | - 'spec/integration/databases/preserve_existing_files_spec.rb' 56 | - 'spec/support/collection_examples.rb' 57 | -------------------------------------------------------------------------------- /spec/integration/cache/cache_env_var_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe "Caching: JEKYLL_NOTION_CACHE" do 6 | let(:cache_dir) { Dir.mktmpdir("jekyll-cache-") } 7 | let(:site) { Jekyll::Site.new(config) } 8 | let(:config) do 9 | Jekyll.configuration( 10 | "source" => SOURCE_DIR, 11 | "destination" => DEST_DIR, 12 | "notion" => { 13 | "cache_dir" => cache_dir, 14 | "pages" => [{ "id" => "9dc17c9c-9d2e-469d-bbf0-f9648f3288d3" }], 15 | } 16 | ) 17 | end 18 | 19 | original_value = nil 20 | 21 | before do 22 | original_value = ENV["JEKYLL_NOTION_CACHE"] 23 | ENV["JEKYLL_NOTION_CACHE"] = falsy_value 24 | 25 | allow(NotionToMd::Page).to receive(:call).and_return( 26 | instance_double("NotionToMd::Page", :title => "blabla", :to_md => "body", :properties => {}) 27 | ) 28 | 29 | site.process 30 | end 31 | 32 | after do 33 | ENV["JEKYLL_NOTION_CACHE"] = original_value 34 | end 35 | 36 | %w(0 false FALSE no NO False).each do |current_value| 37 | context "with #{current_value}" do 38 | let(:falsy_value) { current_value } 39 | 40 | it "disables caching" do 41 | expect(JekyllNotion::Cacheable.enabled?).to be(false) 42 | end 43 | end 44 | end 45 | 46 | %w(1 true TRUE yes YES True).each do |current_value| 47 | context "with #{current_value}" do 48 | let(:falsy_value) { current_value } 49 | 50 | it "enables caching" do 51 | expect(JekyllNotion::Cacheable.enabled?).to be(true) 52 | end 53 | end 54 | end 55 | 56 | context "with empty string" do 57 | let(:falsy_value) { "" } 58 | 59 | it "disables caching" do 60 | expect(JekyllNotion::Cacheable.enabled?).to be(true) 61 | end 62 | end 63 | 64 | context "with nil (unset)" do 65 | let(:falsy_value) { nil } 66 | 67 | before do 68 | ENV.delete("JEKYLL_NOTION_CACHE") 69 | site.process 70 | end 71 | 72 | it "enables caching by default" do 73 | expect(JekyllNotion::Cacheable.enabled?).to be(true) 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /lib/jekyll-notion/generators/collection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module JekyllNotion 4 | module Generators 5 | class Collection < Generator 6 | include Collectionable 7 | 8 | def call 9 | if config["data"].nil? 10 | notion_pages.each { |notion_page| generate_document(notion_page) } 11 | else 12 | Data.call(:config => config, :site => site, 13 | :notion_pages => notion_pages) 14 | end 15 | end 16 | 17 | private 18 | 19 | def generate_document(notion_page) 20 | return if page_exists?(site_collection.docs, notion_page) 21 | 22 | document = make_doc(notion_page) 23 | 24 | site_collection.docs << document 25 | 26 | log_page(document) 27 | end 28 | 29 | def site_collection 30 | @site.collections[collection_name] 31 | end 32 | 33 | def make_doc(page) 34 | new_post = DocumentWithoutAFile.new( 35 | make_path(page), 36 | { :site => @site, :collection => site_collection } 37 | ) 38 | new_post.content = page.to_md 39 | new_post.read 40 | new_post 41 | end 42 | 43 | def make_path(page) 44 | "_#{collection_name}/#{make_filename(page)}" 45 | end 46 | 47 | def collection_name 48 | config["collection"] || "posts" 49 | end 50 | 51 | def make_filename(page) 52 | if collection_name == "posts" 53 | "#{date_for(page)}-#{Jekyll::Utils.slugify(page.title, :mode => "latin")}.md" 54 | else 55 | "#{Jekyll::Utils.slugify(page.title, :mode => "latin")}.md" 56 | end 57 | end 58 | 59 | def date_for(page) 60 | # The "date" property overwrites the Jekyll::Document#data["date"] key 61 | # which is the date used by Jekyll to set the post date. 62 | Time.parse(page.props["date"]).to_date 63 | rescue TypeError, NoMethodError 64 | # Because the "date" property is not required, 65 | # it fallbacks to the created_time which is always present. 66 | Time.parse(page.created_time).to_date 67 | end 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /spec/fixtures/golden/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/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 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
0.0 cell0.1 cell
1.0 cell1.1 cell
2.0 cell2.1 cell
17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 |
header 0header 1header 2
0.0 cell1.0 cell2.0 cell
0.1 cell1.1 cell2.1 cell
39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 |
 header 1header 2
row 11.0 cell2.0 cell
row 21.1 cell2.1 cell
61 | 62 |

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 | 
72 | 73 |
    74 |
  • blabla 1 75 |
      76 |
    • blabla 2 77 |
        78 |
      • blabla 5
      • 79 |
      80 |
    • 81 |
    • blabla3
    • 82 |
    83 |
  • 84 |
  • blabla 4
  • 85 |
86 | 87 | -------------------------------------------------------------------------------- /spec/fixtures/golden/2022-09-17-tables.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
0.0 cell0.1 cell
1.0 cell1.1 cell
2.0 cell2.1 cell
17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 |
header 0header 1header 2
0.0 cell1.0 cell2.0 cell
0.1 cell1.1 cell2.1 cell
39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 |
 header 1header 2
row 11.0 cell2.0 cell
row 21.1 cell2.1 cell
61 | 62 |

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 | 
72 | 73 |
    74 |
  • blabla 1 75 |
      76 |
    • blabla 2 77 |
        78 |
      • blabla 5
      • 79 |
      80 |
    • 81 |
    • blabla3
    • 82 |
    83 |
  • 84 |
  • blabla 4
  • 85 |
86 | 87 | -------------------------------------------------------------------------------- /spec/fixtures/golden/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/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 |

Heading 1

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 |

Heading 2

6 | 7 |

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 |

Heading 3

10 | 11 |

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 |

Heading 1

14 | 15 |

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 |

Heading 1

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 |

Heading 2

6 | 7 |

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 |

Heading 3

10 | 11 |

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 |

Heading 1

14 | 15 |

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 |

Heading 1

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 |

Heading 2

6 | 7 |

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 |

Heading 3

10 | 11 |

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 |

Heading 1

14 | 15 |

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("

Test

\n

Content

") 109 | 110 | result = generator.send(:convert, page) 111 | expect(result).to eq("

Test

\n

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= 78 | ``` 79 | 80 | ### Environment Variables 81 | 82 | The plugin supports the following environment variables for configuration: 83 | 84 | - **`NOTION_TOKEN`** (required): Your Notion integration secret token 85 | - **`JEKYLL_NOTION_CACHE`**: Fallback cache setting when not specified in `_config.yml` (`1`, `true`, `yes` to enable; `0`, `false`, `no` to disable) 86 | - **`JEKYLL_NOTION_CACHE_DIR`**: Fallback cache directory when not specified in `_config.yml` (defaults to `.cache/jekyll-notion/vcr_cassettes`) 87 | 88 | Example usage: 89 | ``` bash 90 | export NOTION_TOKEN=secret_abc123... 91 | export JEKYLL_NOTION_CACHE=false 92 | export JEKYLL_NOTION_CACHE_DIR=/tmp/my-custom-cache 93 | ``` 94 | 95 | ### Databases 96 | 97 | Share a [Notion 98 | database](https://developers.notion.com/docs/working-with-databases), 99 | then specify its `id` in `_config.yml`: 100 | 101 | ``` yaml 102 | notion: 103 | databases: 104 | - id: 5cfed4de3bdc4f43ae8ba653a7a2219b 105 | ``` 106 | 107 | By default, entries will be added to the `posts` collection. 108 | 109 | You can also define **multiple databases**: 110 | 111 | ``` yaml 112 | collections: 113 | - recipes 114 | - films 115 | 116 | notion: 117 | databases: 118 | - id: b0e688e199af4295ae80b67eb52f2e2f 119 | - id: 2190450d4cb34739a5c8340c4110fe21 120 | collection: recipes 121 | - id: e42383cd49754897b967ce453760499f 122 | collection: films 123 | ``` 124 | 125 | After running `jekyll build` or `jekyll serve`, the `posts`, `recipes`, 126 | and `films` collections will contain pages from the specified databases. 127 | 128 | #### Database options 129 | 130 | Each database supports the following options: 131 | 132 | - `id`: the unique Notion database ID 133 | - `collection`: which collection to assign pages to (`posts` by 134 | default) 135 | - `filter`: a database 136 | [filter](https://developers.notion.com/reference/post-database-query-filter) 137 | - `sorts`: database [sorting 138 | criteria](https://developers.notion.com/reference/post-database-query-sort) 139 | 140 | ``` yaml 141 | notion: 142 | databases: 143 | - id: e42383cd49754897b967ce453760499f 144 | collection: posts 145 | filter: { "property": "Published", "checkbox": { "equals": true } } 146 | sorts: [{ "timestamp": "created_time", "direction": "ascending" }] 147 | ``` 148 | 149 | #### Post dates 150 | 151 | By default, the Notion page `created_time` property sets the post 152 | filename date. This value is used for Jekyll's [`date` 153 | variable\`](https://jekyllrb.com/docs/front-matter/#predefined-variables-for-posts). 154 | 155 | Since `created_time` cannot be modified, you can override it by adding a 156 | custom Notion property named `date` (or `Date`). That property will be 157 | used instead. 158 | 159 | ### Pages 160 | 161 | You can also load individual Notion pages: 162 | 163 | ``` yaml 164 | notion: 165 | pages: 166 | - id: 5cfed4de3bdc4f43ae8ba653a7a2219b 167 | ``` 168 | 169 | Multiple pages are supported: 170 | 171 | ``` yaml 172 | notion: 173 | pages: 174 | - id: e42383cd49754897b967ce453760499f 175 | - id: b0e688e199af4295ae80b67eb52f2e2f 176 | - id: 2190450d4cb34739a5c8340c4110fe21 177 | ``` 178 | 179 | The generated filename is based on the Notion page title (see [Page 180 | filename](#page-filename)). 181 | 182 | All page properties are exposed as Jekyll front matter. For example, if 183 | a page has a `permalink` property set to `/about/`, Jekyll will generate 184 | `/about/index.html`. 185 | 186 | ### Data 187 | 188 | Instead of adding Notion pages to collections or `pages`, you can store 189 | them under the Jekyll **data object** using the `data` option: 190 | 191 | ``` yaml 192 | notion: 193 | databases: 194 | - id: b0e688e199af4295ae80b67eb52f2e2f 195 | - id: e42383cd49754897b967ce453760499f 196 | data: films 197 | pages: 198 | - id: e42383cd49754897b967ce453760499f 199 | - id: b0e688e199af4295ae80b67eb52f2e2f 200 | data: about 201 | ``` 202 | 203 | Each page is stored as a hash. The page body is available under the 204 | `content` key. 205 | 206 | Example: 207 | 208 | ``` html 209 |
    210 | {% for film in site.data.films %} 211 |
  • {{ film.title }}
  • 212 | {% endfor %} 213 |
214 | 215 | {{ site.data.about.content }} 216 | ``` 217 | 218 | Other properties are mapped normally (see [Notion 219 | properties](#notion-properties)). 220 | 221 | ### Cache 222 | 223 | All Notion requests are cached locally with the [VCR](https://github.com/vcr/vcr) gem to speed up rebuilds. 224 | The first build fetches from the Notion API; subsequent builds reuse the cache. 225 | 226 | The cache mechanism provides: 227 | 228 | - Per-page cache files that include the Notion page title + ID, making them easy to identify. 229 | - Page-level deletion: remove a single cached page without affecting others. 230 | - Databases fetched on every rebuild: new content in Notion is always discovered, while cached pages prevent unnecessary re-fetches. 231 | 232 | **Example cached file (title + ID):** 233 | ```bash 234 | .cache/jekyll-notion/vcr_cassettes/my-page-title-e42383cd49754897b967ce453760499f.yml 235 | ``` 236 | 237 | #### Cache folder 238 | 239 | Default: `.cache/jekyll-notion/vcr_cassettes` 240 | 241 | You can override the cache directory in two ways: 242 | 243 | **Option 1: Configuration file** (in `_config.yml`): 244 | ``` yaml 245 | notion: 246 | cache_dir: another/folder 247 | ``` 248 | 249 | **Option 2: Environment variable**: 250 | ``` bash 251 | export JEKYLL_NOTION_CACHE_DIR=/path/to/custom/cache 252 | ``` 253 | 254 | The `_config.yml` setting takes precedence over the environment variable. 255 | Both relative and absolute paths are supported - relative paths are resolved 256 | from the project root. 257 | 258 | #### Cleaning the cache 259 | 260 | - Delete the entire cache folder to reset everything. 261 | - Or delete a single cached page file to refresh only that page. 262 | 263 | #### Disabling the cache 264 | 265 | To disable caching entirely: 266 | 267 | ``` yaml 268 | notion: 269 | cache: false 270 | ``` 271 | 272 | Or use the `JEKYLL_NOTION_CACHE` environment variable: 273 | 274 | ```bash 275 | export JEKYLL_NOTION_CACHE=false # or 0, no 276 | ``` 277 | 278 | ## Sensitive data 279 | 280 | The cache stores full request and response payloads from the Notion API. 281 | This may include sensitive information such as authentication tokens, URLs, or private content. 282 | 283 | If you intend to store cached files in version control or share them with others, be mindful of what they contain. 284 | By default, jekyll-notion automatically redacts the `NOTION_TOKEN` from all cache files. 285 | If you need to mask additional values, you can configure [VCR filters](https://benoittgt.github.io/vcr/#/configuration/filter_sensitive_data?id=filter-sensitive-data). 286 | 287 | For example, add a file `_plugins/vcr_config.rb`: 288 | 289 | ```ruby 290 | VCR.configure do |config| 291 | # Already handled by jekyll-notion: NOTION_TOKEN 292 | # Example of masking a custom header or property: 293 | config.filter_sensitive_data("[MASKED]") do |interaction| 294 | interaction.request.headers["User-Agent"]&.first 295 | end 296 | end 297 | ``` 298 | 299 | This file will be automatically picked up by Jekyll and merged into the VCR configuration provided by jekyll-notion. 300 | 301 | You can add filters for headers, query parameters, or any other values you don’t want exposed in the cache. 302 | 303 | ## Notion properties 304 | 305 | Notion page properties are mapped into each Jekyll document's front 306 | matter. 307 | 308 | See the companion gem 309 | [notion_to_md](https://github.com/emoriarty/notion_to_md/) for details. 310 | 311 | ## Page filename 312 | 313 | Jekyll distinguishes between **posts** and **other documents**: 314 | 315 | - **Posts**: filenames follow the format 316 | `YEAR-MONTH-DAY-title.MARKUP`, where the date comes from the Notion 317 | `created_time` (or the `date` property if present). 318 | - **Other documents**: filenames are derived from the Notion page 319 | title. 320 | 321 | ## Testing 322 | 323 | Run the test suite: 324 | 325 | ```bash 326 | bundle exec rspec # Run all tests 327 | bundle exec rspec spec/path/to/test # Run specific test file 328 | ``` 329 | 330 | ### Golden Files 331 | 332 | Tests use golden files to validate generated output against known-good snapshots. Update snapshots when expected output changes: 333 | 334 | ```bash 335 | UPDATE_GOLDEN=1 bundle exec rspec 336 | ``` 337 | 338 | -------------------------------------------------------------------------------- /spec/fixtures/spec_cache/pages/page-3-6c9343606ef64b12abb6bb9dc0d53622.yml: -------------------------------------------------------------------------------- 1 | --- 2 | http_interactions: 3 | - request: 4 | method: get 5 | uri: https://api.notion.com/v1/pages/6c934360-6ef6-4b12-abb6-bb9dc0d53622 6 | body: 7 | encoding: US-ASCII 8 | string: '' 9 | headers: 10 | Accept: 11 | - application/json; charset=utf-8 12 | User-Agent: 13 | - Notion Ruby Client/1.2.2 14 | Authorization: 15 | - "[AUTH_REDACTED]" 16 | Notion-Version: 17 | - '2022-02-22' 18 | Accept-Encoding: 19 | - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 20 | response: 21 | status: 22 | code: 200 23 | message: OK 24 | headers: 25 | Date: 26 | - Mon, 08 Sep 2025 10:26:55 GMT 27 | Content-Type: 28 | - application/json; charset=utf-8 29 | Transfer-Encoding: 30 | - chunked 31 | Connection: 32 | - keep-alive 33 | Server: 34 | - cloudflare 35 | Cf-Ray: 36 | - 97bdc010ab9273d3-MRS 37 | Cf-Cache-Status: 38 | - DYNAMIC 39 | Etag: 40 | - W/"7a5-pF1tmdhqdLFafBBHFuJxCHmvVSQ" 41 | Strict-Transport-Security: 42 | - max-age=31536000; includeSubDomains; preload 43 | Vary: 44 | - Accept-Encoding 45 | Content-Security-Policy: 46 | - default-src 'none' 47 | Referrer-Policy: 48 | - strict-origin-when-cross-origin 49 | X-Content-Type-Options: 50 | - nosniff 51 | X-Dns-Prefetch-Control: 52 | - 'off' 53 | X-Download-Options: 54 | - noopen 55 | X-Frame-Options: 56 | - SAMEORIGIN 57 | X-Notion-Request-Id: 58 | - e6ea6cef-2db3-40d6-bc03-2af801fa3e22 59 | X-Permitted-Cross-Domain-Policies: 60 | - none 61 | X-Xss-Protection: 62 | - '0' 63 | Set-Cookie: 64 | - "[COOKIE_REDACTED]" 65 | - "[COOKIE_REDACTED]" 66 | Alt-Svc: 67 | - h3=":443"; ma=86400 68 | body: 69 | encoding: ASCII-8BIT 70 | string: '{"object":"page","id":"6c934360-6ef6-4b12-abb6-bb9dc0d53622","created_time":"2022-01-23T12:31:00.000Z","last_edited_time":"2022-03-05T23:48:00.000Z","created_by":{"object":"user","id":"db313571-0280-411f-a6de-70e826421d12"},"last_edited_by":{"object":"user","id":"db313571-0280-411f-a6de-70e826421d12"},"cover":null,"icon":null,"parent":{"type":"database_id","database_id":"1ae33dd5-f331-4402-9480-69517fa40ae2"},"archived":false,"in_trash":false,"properties":{"empty 71 | date":{"id":"%3A%7BfH","type":"date","date":null},"Multi Select":{"id":"%3C%7Bn%7B","type":"multi_select","multi_select":[{"id":"32e731fc-3fee-41d8-83f5-398b2981f1ac","name":"mselect3","color":"blue"}]},"Select":{"id":"%3EzjF","type":"select","select":{"id":"54e5217b-e8ab-4996-9839-6bed82b39519","name":"select2","color":"orange"}},"date 72 | with time":{"id":"CUEj","type":"date","date":null},"Person":{"id":"TFSp","type":"people","people":[]},"Date":{"id":"T~YB","type":"date","date":null},"Tags":{"id":"UT%3Fx","type":"multi_select","multi_select":[{"id":"3b7a831b-ad2b-4a5b-8e90-6c6de8d0b8e3","name":"tag3","color":"red"}]},"Numbers":{"id":"a~dg","type":"number","number":null},"Rich 73 | Text":{"id":"jJWo","type":"rich_text","rich_text":[]},"Phone":{"id":"k%5DEi","type":"phone_number","phone_number":null},"File":{"id":"x%60rF","type":"files","files":[]},"empty 74 | rich text":{"id":"xtDO","type":"rich_text","rich_text":[]},"Email":{"id":"x%7Bcw","type":"email","email":null},"Checkbox":{"id":"%7CMPu","type":"checkbox","checkbox":false},"empty_select":{"id":"%7DSr%3F","type":"select","select":null},"Name":{"id":"title","type":"title","title":[{"type":"text","text":{"content":"Page 75 | 3","link":null},"annotations":{"bold":false,"italic":false,"strikethrough":false,"underline":false,"code":false,"color":"default"},"plain_text":"Page 76 | 3","href":null}]}},"url":"https://www.notion.so/Page-3-6c9343606ef64b12abb6bb9dc0d53622","public_url":null,"request_id":"e6ea6cef-2db3-40d6-bc03-2af801fa3e22"}' 77 | recorded_at: Mon, 08 Sep 2025 10:26:28 GMT 78 | - request: 79 | method: get 80 | uri: https://api.notion.com/v1/blocks/6c934360-6ef6-4b12-abb6-bb9dc0d53622/children 81 | body: 82 | encoding: US-ASCII 83 | string: '' 84 | headers: 85 | Accept: 86 | - application/json; charset=utf-8 87 | User-Agent: 88 | - Notion Ruby Client/1.2.2 89 | Authorization: 90 | - "[AUTH_REDACTED]" 91 | Notion-Version: 92 | - '2022-02-22' 93 | Accept-Encoding: 94 | - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 95 | response: 96 | status: 97 | code: 200 98 | message: OK 99 | headers: 100 | Date: 101 | - Mon, 08 Sep 2025 10:26:55 GMT 102 | Content-Type: 103 | - application/json; charset=utf-8 104 | Transfer-Encoding: 105 | - chunked 106 | Connection: 107 | - keep-alive 108 | Server: 109 | - cloudflare 110 | Cf-Ray: 111 | - 97bdc016fc2f168f-MRS 112 | Cf-Cache-Status: 113 | - DYNAMIC 114 | Etag: 115 | - W/"1edb-BM8U5FqnCZH8DErak8upK9xjG/Q" 116 | Strict-Transport-Security: 117 | - max-age=31536000; includeSubDomains; preload 118 | Vary: 119 | - Accept-Encoding 120 | Content-Security-Policy: 121 | - default-src 'none' 122 | Referrer-Policy: 123 | - strict-origin-when-cross-origin 124 | X-Content-Type-Options: 125 | - nosniff 126 | X-Dns-Prefetch-Control: 127 | - 'off' 128 | X-Download-Options: 129 | - noopen 130 | X-Frame-Options: 131 | - SAMEORIGIN 132 | X-Notion-Request-Id: 133 | - c966bcf0-e22f-401d-b614-2026118ad531 134 | X-Permitted-Cross-Domain-Policies: 135 | - none 136 | X-Xss-Protection: 137 | - '0' 138 | Set-Cookie: 139 | - "[COOKIE_REDACTED]" 140 | - "[COOKIE_REDACTED]" 141 | Alt-Svc: 142 | - h3=":443"; ma=86400 143 | body: 144 | encoding: ASCII-8BIT 145 | string: '{"object":"list","results":[{"object":"block","id":"95e91a0a-9577-45dc-bb2d-fe2c89ccc272","parent":{"type":"page_id","page_id":"6c934360-6ef6-4b12-abb6-bb9dc0d53622"},"created_time":"2022-02-05T16:29:00.000Z","last_edited_time":"2022-03-05T23:43:00.000Z","created_by":{"object":"user","id":"db313571-0280-411f-a6de-70e826421d12"},"last_edited_by":{"object":"user","id":"db313571-0280-411f-a6de-70e826421d12"},"has_children":false,"archived":false,"in_trash":false,"type":"paragraph","paragraph":{"rich_text":[{"type":"text","text":{"content":"Lorem 146 | ipsum dolor sit amet, consectetur adipiscing elit. Integer tempus semper risus, 147 | non iaculis nisi. Praesent ut magna auctor, consequat metus in, hendrerit 148 | ligula. Maecenas sagittis pulvinar metus, ut blandit ex tincidunt quis. Quisque 149 | ullamcorper urna sapien, vitae malesuada libero auctor eget. Etiam eu neque 150 | tellus. Nam et purus at orci aliquam malesuada non a libero. Vivamus at condimentum 151 | dolor. Duis blandit tincidunt quam, quis pellentesque tellus auctor in. Praesent 152 | vel ligula felis. Ut pellentesque scelerisque metus vitae vehicula. Vivamus 153 | lacinia rhoncus maximus. Duis id ligula et ex suscipit tincidunt.","link":null},"annotations":{"bold":false,"italic":false,"strikethrough":false,"underline":false,"code":false,"color":"default"},"plain_text":"Lorem 154 | ipsum dolor sit amet, consectetur adipiscing elit. Integer tempus semper risus, 155 | non iaculis nisi. Praesent ut magna auctor, consequat metus in, hendrerit 156 | ligula. Maecenas sagittis pulvinar metus, ut blandit ex tincidunt quis. Quisque 157 | ullamcorper urna sapien, vitae malesuada libero auctor eget. Etiam eu neque 158 | tellus. Nam et purus at orci aliquam malesuada non a libero. Vivamus at condimentum 159 | dolor. Duis blandit tincidunt quam, quis pellentesque tellus auctor in. Praesent 160 | vel ligula felis. Ut pellentesque scelerisque metus vitae vehicula. Vivamus 161 | lacinia rhoncus maximus. Duis id ligula et ex suscipit tincidunt.","href":null}],"color":"default"}},{"object":"block","id":"2869b32c-c045-4ab5-b06c-f5dd54f65015","parent":{"type":"page_id","page_id":"6c934360-6ef6-4b12-abb6-bb9dc0d53622"},"created_time":"2022-03-05T23:46:00.000Z","last_edited_time":"2022-03-05T23:46:00.000Z","created_by":{"object":"user","id":"db313571-0280-411f-a6de-70e826421d12"},"last_edited_by":{"object":"user","id":"db313571-0280-411f-a6de-70e826421d12"},"has_children":false,"archived":false,"in_trash":false,"type":"paragraph","paragraph":{"rich_text":[{"type":"text","text":{"content":"Lorem 162 | ipsum dolor sit amet, consectetur adipiscing elit. Integer tempus semper risus, 163 | non iaculis nisi. Praesent ut magna auctor, consequat metus in, hendrerit 164 | ligula. Maecenas sagittis pulvinar metus, ut blandit ex tincidunt quis. Quisque 165 | ullamcorper urna sapien, vitae malesuada libero auctor eget. Etiam eu neque 166 | tellus. Nam et purus at orci aliquam malesuada non a libero. Vivamus at condimentum 167 | dolor. Duis blandit tincidunt quam, quis pellentesque tellus auctor in. Praesent 168 | vel ligula felis. Ut pellentesque scelerisque metus vitae vehicula. Vivamus 169 | lacinia rhoncus maximus. Duis id ligula et ex suscipit tincidunt.","link":null},"annotations":{"bold":false,"italic":false,"strikethrough":false,"underline":false,"code":false,"color":"default"},"plain_text":"Lorem 170 | ipsum dolor sit amet, consectetur adipiscing elit. Integer tempus semper risus, 171 | non iaculis nisi. Praesent ut magna auctor, consequat metus in, hendrerit 172 | ligula. Maecenas sagittis pulvinar metus, ut blandit ex tincidunt quis. Quisque 173 | ullamcorper urna sapien, vitae malesuada libero auctor eget. Etiam eu neque 174 | tellus. Nam et purus at orci aliquam malesuada non a libero. Vivamus at condimentum 175 | dolor. Duis blandit tincidunt quam, quis pellentesque tellus auctor in. Praesent 176 | vel ligula felis. Ut pellentesque scelerisque metus vitae vehicula. Vivamus 177 | lacinia rhoncus maximus. Duis id ligula et ex suscipit tincidunt.","href":null}],"color":"default"}},{"object":"block","id":"169427b5-4f8c-4385-840a-6fcd253303da","parent":{"type":"page_id","page_id":"6c934360-6ef6-4b12-abb6-bb9dc0d53622"},"created_time":"2022-03-05T23:47:00.000Z","last_edited_time":"2022-03-05T23:47:00.000Z","created_by":{"object":"user","id":"db313571-0280-411f-a6de-70e826421d12"},"last_edited_by":{"object":"user","id":"db313571-0280-411f-a6de-70e826421d12"},"has_children":false,"archived":false,"in_trash":false,"type":"paragraph","paragraph":{"rich_text":[{"type":"text","text":{"content":"Lorem 178 | ipsum dolor sit amet, consectetur adipiscing elit. Integer tempus semper risus, 179 | non iaculis nisi. Praesent ut magna auctor, consequat metus in, hendrerit 180 | ligula. Maecenas sagittis pulvinar metus, ut blandit ex tincidunt quis. Quisque 181 | ullamcorper urna sapien, vitae malesuada libero auctor eget. Etiam eu neque 182 | tellus. Nam et purus at orci aliquam malesuada non a libero. Vivamus at condimentum 183 | dolor. Duis blandit tincidunt quam, quis pellentesque tellus auctor in. Praesent 184 | vel ligula felis. Ut pellentesque scelerisque metus vitae vehicula. Vivamus 185 | lacinia rhoncus maximus. Duis id ligula et ex suscipit tincidunt.","link":null},"annotations":{"bold":false,"italic":false,"strikethrough":false,"underline":false,"code":false,"color":"default"},"plain_text":"Lorem 186 | ipsum dolor sit amet, consectetur adipiscing elit. Integer tempus semper risus, 187 | non iaculis nisi. Praesent ut magna auctor, consequat metus in, hendrerit 188 | ligula. Maecenas sagittis pulvinar metus, ut blandit ex tincidunt quis. Quisque 189 | ullamcorper urna sapien, vitae malesuada libero auctor eget. Etiam eu neque 190 | tellus. Nam et purus at orci aliquam malesuada non a libero. Vivamus at condimentum 191 | dolor. Duis blandit tincidunt quam, quis pellentesque tellus auctor in. Praesent 192 | vel ligula felis. Ut pellentesque scelerisque metus vitae vehicula. Vivamus 193 | lacinia rhoncus maximus. Duis id ligula et ex suscipit tincidunt.","href":null}],"color":"default"}},{"object":"block","id":"b020b5b7-0a1c-4cb0-9dd3-939c49f69a94","parent":{"type":"page_id","page_id":"6c934360-6ef6-4b12-abb6-bb9dc0d53622"},"created_time":"2022-03-05T23:48:00.000Z","last_edited_time":"2022-03-05T23:48:00.000Z","created_by":{"object":"user","id":"db313571-0280-411f-a6de-70e826421d12"},"last_edited_by":{"object":"user","id":"db313571-0280-411f-a6de-70e826421d12"},"has_children":false,"archived":false,"in_trash":false,"type":"paragraph","paragraph":{"rich_text":[{"type":"text","text":{"content":"Lorem 194 | ipsum dolor sit amet, consectetur adipiscing elit. Integer tempus semper risus, 195 | non iaculis nisi. Praesent ut magna auctor, consequat metus in, hendrerit 196 | ligula. Maecenas sagittis pulvinar metus, ut blandit ex tincidunt quis. Quisque 197 | ullamcorper urna sapien, vitae malesuada libero auctor eget. Etiam eu neque 198 | tellus. Nam et purus at orci aliquam malesuada non a libero. Vivamus at condimentum 199 | dolor. Duis blandit tincidunt quam, quis pellentesque tellus auctor in. Praesent 200 | vel ligula felis. Ut pellentesque scelerisque metus vitae vehicula. Vivamus 201 | lacinia rhoncus maximus. Duis id ligula et ex suscipit tincidunt.","link":null},"annotations":{"bold":false,"italic":false,"strikethrough":false,"underline":false,"code":false,"color":"default"},"plain_text":"Lorem 202 | ipsum dolor sit amet, consectetur adipiscing elit. Integer tempus semper risus, 203 | non iaculis nisi. Praesent ut magna auctor, consequat metus in, hendrerit 204 | ligula. Maecenas sagittis pulvinar metus, ut blandit ex tincidunt quis. Quisque 205 | ullamcorper urna sapien, vitae malesuada libero auctor eget. Etiam eu neque 206 | tellus. Nam et purus at orci aliquam malesuada non a libero. Vivamus at condimentum 207 | dolor. Duis blandit tincidunt quam, quis pellentesque tellus auctor in. Praesent 208 | vel ligula felis. Ut pellentesque scelerisque metus vitae vehicula. Vivamus 209 | lacinia rhoncus maximus. Duis id ligula et ex suscipit tincidunt.","href":null}],"color":"default"}}],"next_cursor":null,"has_more":false,"type":"block","block":{},"request_id":"c966bcf0-e22f-401d-b614-2026118ad531"}' 210 | recorded_at: Mon, 08 Sep 2025 10:26:29 GMT 211 | recorded_with: VCR 6.3.1 212 | -------------------------------------------------------------------------------- /spec/fixtures/spec_cache/pages/title-with-double-quotes-and-single-quotes-and-colons-but-forget-àccénts-àáâãäāăȧǎȁȃ-9349e5108c0e4c0ea772b187d63ecfe1.yml: -------------------------------------------------------------------------------- 1 | --- 2 | http_interactions: 3 | - request: 4 | method: get 5 | uri: https://api.notion.com/v1/pages/9349e510-8c0e-4c0e-a772-b187d63ecfe1 6 | body: 7 | encoding: US-ASCII 8 | string: '' 9 | headers: 10 | Accept: 11 | - application/json; charset=utf-8 12 | User-Agent: 13 | - Notion Ruby Client/1.2.2 14 | Authorization: 15 | - "[AUTH_REDACTED]" 16 | Notion-Version: 17 | - '2022-02-22' 18 | Accept-Encoding: 19 | - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 20 | response: 21 | status: 22 | code: 200 23 | message: OK 24 | headers: 25 | Date: 26 | - Mon, 08 Sep 2025 10:26:33 GMT 27 | Content-Type: 28 | - application/json; charset=utf-8 29 | Transfer-Encoding: 30 | - chunked 31 | Connection: 32 | - keep-alive 33 | Server: 34 | - cloudflare 35 | Cf-Ray: 36 | - 97bdbf8ded86e18d-MRS 37 | Cf-Cache-Status: 38 | - DYNAMIC 39 | Etag: 40 | - W/"a66-VPWp/mBTgFX3hPfmrtnhGOrSGE8" 41 | Strict-Transport-Security: 42 | - max-age=31536000; includeSubDomains; preload 43 | Vary: 44 | - Accept-Encoding 45 | Content-Security-Policy: 46 | - default-src 'none' 47 | Referrer-Policy: 48 | - strict-origin-when-cross-origin 49 | X-Content-Type-Options: 50 | - nosniff 51 | X-Dns-Prefetch-Control: 52 | - 'off' 53 | X-Download-Options: 54 | - noopen 55 | X-Frame-Options: 56 | - SAMEORIGIN 57 | X-Notion-Request-Id: 58 | - 16cd3c49-4a25-4e35-990b-8f80e9b4127e 59 | X-Permitted-Cross-Domain-Policies: 60 | - none 61 | X-Xss-Protection: 62 | - '0' 63 | Set-Cookie: 64 | - "[COOKIE_REDACTED]" 65 | - "[COOKIE_REDACTED]" 66 | Alt-Svc: 67 | - h3=":443"; ma=86400 68 | body: 69 | encoding: ASCII-8BIT 70 | string: !binary |- 71 | eyJvYmplY3QiOiJwYWdlIiwiaWQiOiI5MzQ5ZTUxMC04YzBlLTRjMGUtYTc3Mi1iMTg3ZDYzZWNmZTEiLCJjcmVhdGVkX3RpbWUiOiIyMDIzLTEyLTAxVDE3OjE3OjAwLjAwMFoiLCJsYXN0X2VkaXRlZF90aW1lIjoiMjAyNS0wOC0zMFQwNToxMjowMC4wMDBaIiwiY3JlYXRlZF9ieSI6eyJvYmplY3QiOiJ1c2VyIiwiaWQiOiJkYjMxMzU3MS0wMjgwLTQxMWYtYTZkZS03MGU4MjY0MjFkMTIifSwibGFzdF9lZGl0ZWRfYnkiOnsib2JqZWN0IjoidXNlciIsImlkIjoiZGIzMTM1NzEtMDI4MC00MTFmLWE2ZGUtNzBlODI2NDIxZDEyIn0sImNvdmVyIjpudWxsLCJpY29uIjpudWxsLCJwYXJlbnQiOnsidHlwZSI6ImRhdGFiYXNlX2lkIiwiZGF0YWJhc2VfaWQiOiIxYWUzM2RkNS1mMzMxLTQ0MDItOTQ4MC02OTUxN2ZhNDBhZTIifSwiYXJjaGl2ZWQiOmZhbHNlLCJpbl90cmFzaCI6ZmFsc2UsInByb3BlcnRpZXMiOnsiZW1wdHkgZGF0ZSI6eyJpZCI6IiUzQSU3QmZIIiwidHlwZSI6ImRhdGUiLCJkYXRlIjpudWxsfSwiTXVsdGkgU2VsZWN0Ijp7ImlkIjoiJTNDJTdCbiU3QiIsInR5cGUiOiJtdWx0aV9zZWxlY3QiLCJtdWx0aV9zZWxlY3QiOlt7ImlkIjoiNWUyZDYwMGUtNDNiOS00YzVjLTg0ZGMtZGI5NTA0MzUzOTgxIiwibmFtZSI6ImRzZDogZGQiLCJjb2xvciI6ImJyb3duIn0seyJpZCI6ImRkZjVlMzBlLWZkZTYtNDAyMy05OGE0LTg2NDY3NGU4YWU4NyIsIm5hbWUiOiJtc2VsZWN0MSIsImNvbG9yIjoiZ3JheSJ9XX0sIlNlbGVjdCI6eyJpZCI6IiUzRXpqRiIsInR5cGUiOiJzZWxlY3QiLCJzZWxlY3QiOnsiaWQiOiI5YjgxZGEwOC1iMzBiLTRjNWItYmE2OS0yOWI2NDE0Y2U4MWUiLCJuYW1lIjoiYmxhYmw6IGJrYSIsImNvbG9yIjoiZGVmYXVsdCJ9fSwiZGF0ZSB3aXRoIHRpbWUiOnsiaWQiOiJDVUVqIiwidHlwZSI6ImRhdGUiLCJkYXRlIjpudWxsfSwiUGVyc29uIjp7ImlkIjoiVEZTcCIsInR5cGUiOiJwZW9wbGUiLCJwZW9wbGUiOltdfSwiRGF0ZSI6eyJpZCI6IlR+WUIiLCJ0eXBlIjoiZGF0ZSIsImRhdGUiOm51bGx9LCJUYWdzIjp7ImlkIjoiVVQlM0Z4IiwidHlwZSI6Im11bHRpX3NlbGVjdCIsIm11bHRpX3NlbGVjdCI6W3siaWQiOiIxNzc2ZDgzNS1lZThjLTRhMjAtODcyNi01MTFiOTlmYTBhOTUiLCJuYW1lIjoidGFnOiB0YWciLCJjb2xvciI6InllbGxvdyJ9XX0sIk51bWJlcnMiOnsiaWQiOiJhfmRnIiwidHlwZSI6Im51bWJlciIsIm51bWJlciI6bnVsbH0sIlJpY2ggVGV4dCI6eyJpZCI6ImpKV28iLCJ0eXBlIjoicmljaF90ZXh0IiwicmljaF90ZXh0IjpbeyJ0eXBlIjoidGV4dCIsInRleHQiOnsiY29udGVudCI6IlJpY2ggdGV4dDogd2l0aCDigJxkb3VibGUgcXVvdGVz4oCdIGFuZCDigJhzaW5nbGUgcXVvdGVz4oCZIGFuZCA6Y29sb25zOiIsImxpbmsiOm51bGx9LCJhbm5vdGF0aW9ucyI6eyJib2xkIjpmYWxzZSwiaXRhbGljIjpmYWxzZSwic3RyaWtldGhyb3VnaCI6ZmFsc2UsInVuZGVybGluZSI6ZmFsc2UsImNvZGUiOmZhbHNlLCJjb2xvciI6ImRlZmF1bHQifSwicGxhaW5fdGV4dCI6IlJpY2ggdGV4dDogd2l0aCDigJxkb3VibGUgcXVvdGVz4oCdIGFuZCDigJhzaW5nbGUgcXVvdGVz4oCZIGFuZCA6Y29sb25zOiIsImhyZWYiOm51bGx9XX0sIlBob25lIjp7ImlkIjoiayU1REVpIiwidHlwZSI6InBob25lX251bWJlciIsInBob25lX251bWJlciI6bnVsbH0sIkZpbGUiOnsiaWQiOiJ4JTYwckYiLCJ0eXBlIjoiZmlsZXMiLCJmaWxlcyI6W119LCJlbXB0eSByaWNoIHRleHQiOnsiaWQiOiJ4dERPIiwidHlwZSI6InJpY2hfdGV4dCIsInJpY2hfdGV4dCI6W119LCJFbWFpbCI6eyJpZCI6InglN0JjdyIsInR5cGUiOiJlbWFpbCIsImVtYWlsIjpudWxsfSwiQ2hlY2tib3giOnsiaWQiOiIlN0NNUHUiLCJ0eXBlIjoiY2hlY2tib3giLCJjaGVja2JveCI6ZmFsc2V9LCJlbXB0eV9zZWxlY3QiOnsiaWQiOiIlN0RTciUzRiIsInR5cGUiOiJzZWxlY3QiLCJzZWxlY3QiOm51bGx9LCJOYW1lIjp7ImlkIjoidGl0bGUiLCJ0eXBlIjoidGl0bGUiLCJ0aXRsZSI6W3sidHlwZSI6InRleHQiLCJ0ZXh0Ijp7ImNvbnRlbnQiOiJUaXRsZTogd2l0aCDigJxkb3VibGUgcXVvdGVz4oCdIGFuZCDigJhzaW5nbGUgcXVvdGVz4oCZIGFuZCA6Y29sb25zOiBidXQgZm9yZ2V0IMOgY2PDqW50czogw6DDocOiw6PDpMSBxIPIp8eOyIHIgyIsImxpbmsiOm51bGx9LCJhbm5vdGF0aW9ucyI6eyJib2xkIjpmYWxzZSwiaXRhbGljIjpmYWxzZSwic3RyaWtldGhyb3VnaCI6ZmFsc2UsInVuZGVybGluZSI6ZmFsc2UsImNvZGUiOmZhbHNlLCJjb2xvciI6ImRlZmF1bHQifSwicGxhaW5fdGV4dCI6IlRpdGxlOiB3aXRoIOKAnGRvdWJsZSBxdW90ZXPigJ0gYW5kIOKAmHNpbmdsZSBxdW90ZXPigJkgYW5kIDpjb2xvbnM6IGJ1dCBmb3JnZXQgw6BjY8OpbnRzOiDDoMOhw6LDo8OkxIHEg8inx47IgciDIiwiaHJlZiI6bnVsbH1dfX0sInVybCI6Imh0dHBzOi8vd3d3Lm5vdGlvbi5zby9UaXRsZS13aXRoLWRvdWJsZS1xdW90ZXMtYW5kLXNpbmdsZS1xdW90ZXMtYW5kLWNvbG9ucy1idXQtZm9yZ2V0LWNjLW50cy05MzQ5ZTUxMDhjMGU0YzBlYTc3MmIxODdkNjNlY2ZlMSIsInB1YmxpY191cmwiOm51bGwsInJlcXVlc3RfaWQiOiIxNmNkM2M0OS00YTI1LTRlMzUtOTkwYi04ZjgwZTliNDEyN2UifQ== 72 | recorded_at: Mon, 08 Sep 2025 10:26:07 GMT 73 | - request: 74 | method: get 75 | uri: https://api.notion.com/v1/blocks/9349e510-8c0e-4c0e-a772-b187d63ecfe1/children 76 | body: 77 | encoding: US-ASCII 78 | string: '' 79 | headers: 80 | Accept: 81 | - application/json; charset=utf-8 82 | User-Agent: 83 | - Notion Ruby Client/1.2.2 84 | Authorization: 85 | - "[AUTH_REDACTED]" 86 | Notion-Version: 87 | - '2022-02-22' 88 | Accept-Encoding: 89 | - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 90 | response: 91 | status: 92 | code: 200 93 | message: OK 94 | headers: 95 | Date: 96 | - Mon, 08 Sep 2025 10:26:34 GMT 97 | Content-Type: 98 | - application/json; charset=utf-8 99 | Transfer-Encoding: 100 | - chunked 101 | Connection: 102 | - keep-alive 103 | Server: 104 | - cloudflare 105 | Cf-Ray: 106 | - 97bdbf8ffb4711c0-MRS 107 | Cf-Cache-Status: 108 | - DYNAMIC 109 | Etag: 110 | - W/"194a-Kr+jbpIZu4Au9Kzh25dgo5IRd6Q" 111 | Strict-Transport-Security: 112 | - max-age=31536000; includeSubDomains; preload 113 | Vary: 114 | - Accept-Encoding 115 | Content-Security-Policy: 116 | - default-src 'none' 117 | Referrer-Policy: 118 | - strict-origin-when-cross-origin 119 | X-Content-Type-Options: 120 | - nosniff 121 | X-Dns-Prefetch-Control: 122 | - 'off' 123 | X-Download-Options: 124 | - noopen 125 | X-Frame-Options: 126 | - SAMEORIGIN 127 | X-Notion-Request-Id: 128 | - f3138d79-6b89-4119-b476-f1b061ce0950 129 | X-Permitted-Cross-Domain-Policies: 130 | - none 131 | X-Xss-Protection: 132 | - '0' 133 | Set-Cookie: 134 | - "[COOKIE_REDACTED]" 135 | - "[COOKIE_REDACTED]" 136 | Alt-Svc: 137 | - h3=":443"; ma=86400 138 | body: 139 | encoding: ASCII-8BIT 140 | string: '{"object":"list","results":[{"object":"block","id":"25fdb135-281c-8097-bb60-ea76959e300f","parent":{"type":"page_id","page_id":"9349e510-8c0e-4c0e-a772-b187d63ecfe1"},"created_time":"2025-08-30T05:12:00.000Z","last_edited_time":"2025-08-30T05:12:00.000Z","created_by":{"object":"user","id":"db313571-0280-411f-a6de-70e826421d12"},"last_edited_by":{"object":"user","id":"db313571-0280-411f-a6de-70e826421d12"},"has_children":false,"archived":false,"in_trash":false,"type":"paragraph","paragraph":{"rich_text":[{"type":"text","text":{"content":"Lorem 141 | ipsum dolor sit amet, consectetur adipiscing elit. Proin nec sapien porttitor, 142 | aliquet est dignissim, dapibus libero. Integer sapien tortor, volutpat sed 143 | efficitur sit amet, sagittis ut nisi. Nunc sollicitudin condimentum finibus. 144 | Cras auctor sit amet leo at porttitor. Donec dignissim laoreet tortor, ullamcorper 145 | elementum magna aliquam a. Maecenas viverra, risus eu posuere fringilla, est 146 | odio viverra nisl, sit amet accumsan erat metus egestas massa. Suspendisse 147 | iaculis, ipsum eget viverra suscipit, tortor felis finibus nulla, at mattis 148 | odio mi tincidunt sapien. Mauris quis augue quis risus elementum dapibus eu 149 | ut leo. Etiam leo neque, eleifend sit amet rutrum in, malesuada in turpis. 150 | Donec ex justo, fringilla at cursus id, fermentum eget ante. Nam vitae sapien 151 | velit.","link":null},"annotations":{"bold":false,"italic":false,"strikethrough":false,"underline":false,"code":false,"color":"default"},"plain_text":"Lorem 152 | ipsum dolor sit amet, consectetur adipiscing elit. Proin nec sapien porttitor, 153 | aliquet est dignissim, dapibus libero. Integer sapien tortor, volutpat sed 154 | efficitur sit amet, sagittis ut nisi. Nunc sollicitudin condimentum finibus. 155 | Cras auctor sit amet leo at porttitor. Donec dignissim laoreet tortor, ullamcorper 156 | elementum magna aliquam a. Maecenas viverra, risus eu posuere fringilla, est 157 | odio viverra nisl, sit amet accumsan erat metus egestas massa. Suspendisse 158 | iaculis, ipsum eget viverra suscipit, tortor felis finibus nulla, at mattis 159 | odio mi tincidunt sapien. Mauris quis augue quis risus elementum dapibus eu 160 | ut leo. Etiam leo neque, eleifend sit amet rutrum in, malesuada in turpis. 161 | Donec ex justo, fringilla at cursus id, fermentum eget ante. Nam vitae sapien 162 | velit.","href":null}],"color":"default"}},{"object":"block","id":"25fdb135-281c-80ab-9f96-eeb88be58f23","parent":{"type":"page_id","page_id":"9349e510-8c0e-4c0e-a772-b187d63ecfe1"},"created_time":"2025-08-30T05:12:00.000Z","last_edited_time":"2025-08-30T05:12:00.000Z","created_by":{"object":"user","id":"db313571-0280-411f-a6de-70e826421d12"},"last_edited_by":{"object":"user","id":"db313571-0280-411f-a6de-70e826421d12"},"has_children":false,"archived":false,"in_trash":false,"type":"paragraph","paragraph":{"rich_text":[{"type":"text","text":{"content":"Nullam 163 | vestibulum turpis at dignissim dapibus. In nec velit finibus, egestas purus 164 | nec, accumsan enim. Maecenas in congue massa. Maecenas ipsum mauris, aliquet 165 | quis ex eu, ornare feugiat ex. Sed convallis ullamcorper ipsum, vel ullamcorper 166 | diam imperdiet vitae. Etiam efficitur neque in nibh elementum, non cursus 167 | tellus maximus. Duis magna dui, mattis vitae felis non, fringilla accumsan 168 | erat. Duis scelerisque scelerisque viverra. Sed luctus, mi suscipit bibendum 169 | luctus, sem purus dictum ante, eu cursus ipsum quam sit amet augue.","link":null},"annotations":{"bold":false,"italic":false,"strikethrough":false,"underline":false,"code":false,"color":"default"},"plain_text":"Nullam 170 | vestibulum turpis at dignissim dapibus. In nec velit finibus, egestas purus 171 | nec, accumsan enim. Maecenas in congue massa. Maecenas ipsum mauris, aliquet 172 | quis ex eu, ornare feugiat ex. Sed convallis ullamcorper ipsum, vel ullamcorper 173 | diam imperdiet vitae. Etiam efficitur neque in nibh elementum, non cursus 174 | tellus maximus. Duis magna dui, mattis vitae felis non, fringilla accumsan 175 | erat. Duis scelerisque scelerisque viverra. Sed luctus, mi suscipit bibendum 176 | luctus, sem purus dictum ante, eu cursus ipsum quam sit amet augue.","href":null}],"color":"default"}},{"object":"block","id":"25fdb135-281c-8059-8a52-fae0338537e5","parent":{"type":"page_id","page_id":"9349e510-8c0e-4c0e-a772-b187d63ecfe1"},"created_time":"2025-08-30T05:12:00.000Z","last_edited_time":"2025-08-30T05:12:00.000Z","created_by":{"object":"user","id":"db313571-0280-411f-a6de-70e826421d12"},"last_edited_by":{"object":"user","id":"db313571-0280-411f-a6de-70e826421d12"},"has_children":false,"archived":false,"in_trash":false,"type":"paragraph","paragraph":{"rich_text":[{"type":"text","text":{"content":"Praesent 177 | rhoncus enim dolor, nec tincidunt lacus mollis ut. Phasellus eu ex leo. Donec 178 | nec vulputate nisl. Fusce mattis blandit sem, ac molestie mi placerat quis. 179 | Donec lacinia enim sed nunc pulvinar mattis. Etiam dictum neque quis lacus 180 | congue pulvinar. Nam lectus purus, egestas id feugiat et, molestie sed lacus. 181 | Sed laoreet molestie dolor nec tincidunt. In imperdiet dolor sit amet elit 182 | placerat, et ullamcorper sapien rutrum. Sed elit tellus, rhoncus id erat in, 183 | lobortis volutpat ex. Nullam sit amet commodo lectus, vitae finibus augue. 184 | Integer venenatis lectus ut placerat maximus. Etiam hendrerit nisl eros, eu 185 | ultricies magna malesuada non. Maecenas at quam id elit elementum cursus. 186 | Cras fermentum arcu purus, ac vulputate sem pretium id. Ut placerat metus 187 | turpis, id aliquet sapien placerat eget.","link":null},"annotations":{"bold":false,"italic":false,"strikethrough":false,"underline":false,"code":false,"color":"default"},"plain_text":"Praesent 188 | rhoncus enim dolor, nec tincidunt lacus mollis ut. Phasellus eu ex leo. Donec 189 | nec vulputate nisl. Fusce mattis blandit sem, ac molestie mi placerat quis. 190 | Donec lacinia enim sed nunc pulvinar mattis. Etiam dictum neque quis lacus 191 | congue pulvinar. Nam lectus purus, egestas id feugiat et, molestie sed lacus. 192 | Sed laoreet molestie dolor nec tincidunt. In imperdiet dolor sit amet elit 193 | placerat, et ullamcorper sapien rutrum. Sed elit tellus, rhoncus id erat in, 194 | lobortis volutpat ex. Nullam sit amet commodo lectus, vitae finibus augue. 195 | Integer venenatis lectus ut placerat maximus. Etiam hendrerit nisl eros, eu 196 | ultricies magna malesuada non. Maecenas at quam id elit elementum cursus. 197 | Cras fermentum arcu purus, ac vulputate sem pretium id. Ut placerat metus 198 | turpis, id aliquet sapien placerat eget.","href":null}],"color":"default"}}],"next_cursor":null,"has_more":false,"type":"block","block":{},"request_id":"f3138d79-6b89-4119-b476-f1b061ce0950"}' 199 | recorded_at: Mon, 08 Sep 2025 10:26:08 GMT 200 | recorded_with: VCR 6.3.1 201 | -------------------------------------------------------------------------------- /spec/fixtures/golden/Page 1.html: -------------------------------------------------------------------------------- 1 |

2 | 3 |

John Rambo having a coffee.

4 | 5 |

Heading 1

6 | 7 |

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 |

Heading 2

10 | 11 |

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 |

Heading 3

14 | 15 |

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla quis neque vel odio lobortis

16 | 17 |

Lists

18 | 19 |
    20 |
  • item 1
  • 21 |
  • item 2
  • 22 |
  • item 3
  • 23 |
24 | 25 |
    26 |
  1. item 1
  2. 27 |
  3. item 2
  4. 28 |
  5. item 3
  6. 29 |
30 | 31 |

Nested Lists

32 | 33 |
    34 |
  • item 1 35 |
      36 |
    • item 2 37 |
        38 |
      • item 3
      • 39 |
      40 |
    • 41 |
    • item 4
    • 42 |
    43 |
  • 44 |
45 | 46 |
    47 |
  1. item 1 48 |
      49 |
    1. item 2 50 |
        51 |
      1. item 3
      2. 52 |
      53 |
    2. 54 |
    3. item 4
    4. 55 |
    56 |
  2. 57 |
58 | 59 |

Quotes

60 | 61 |
62 |

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 |
64 | 65 |

Callout

66 | 67 |
68 |

💡 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 |
70 | 71 |

Embed

72 | 73 |

https://www.google.com/maps/place/Guadalajara,+Jal.,+M%C3%A9xico/@20.6737883,-103.3704326,13z/data=!3m1!4b1!4m5!3m4!1s0x8428b18cb52fd39b:0xd63d9302bf865750!8m2!3d20.6596988!4d-103.3496092

74 | 75 |

Bookmark

76 | 77 |

https://enriq.me

78 | 79 |
80 | 81 |

This is an equation: $E=mc^2$.

82 | 83 | 84 | 85 |

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 |

Code

88 | 89 |
function fn(a) {
 90 | 	return a;
 91 | }
 92 | 
93 | 94 |
This is a plain text
 95 | 
96 | 97 |

To do

98 | 99 |
    100 |
  • blabla 1
  • 101 |
  • blabla 2
  • 102 |
  • blabla 3
  • 103 |
104 | 105 |


106 | 107 |

italic **bold **~~strike ~~inline-code underline

108 | 109 |


110 | 111 |

italic

112 | 113 |

bold

114 | 115 |

strike-trough

116 | 117 |

underline

118 | 119 |

inline-code

120 | 121 |

Tables

122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 |
 Column 1Column 1
Row 1ñañablabla
Row 2pruprutinonino
144 | 145 | 146 | 147 |

https://github.com/emoriarty/notion_to_md/pull/74

148 | 149 |

Files

150 | 151 |

https://prod-files-secure.s3.us-west-2.amazonaws.com/4783548e-2442-4bf3-bb3d-ed4ddd2dcdf0/4319bd9c-96de-4b5d-953e-7d7df524d69a/sample_file.txt?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=ASIAZI2LB4662BTHUMP6%2F20250908%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Date=20250908T102656Z&X-Amz-Expires=3600&X-Amz-Security-Token=IQoJb3JpZ2luX2VjEFIaCXVzLXdlc3QtMiJHMEUCIQClISjXW088YSbVXYqxQOidsPLwjDpcj17GKYE7pJtj%2FQIgOqO9VQetih0Js%2BNpxW1DKD2GqG2ovLx1ehqXEraNpiMqiAQIu%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FARAAGgw2Mzc0MjMxODM4MDUiDAA%2F5WP8ZffIEpjPuyrcA5nhH9CGT4d9s44fyZxmFIa4cdBeN91EeaKf1wdWWOmOoPfMzPF1%2FslfHJphsWsphijKGU46mjku6DxRBFqxi5DrK4UtuNAGnGzJfgUfgMOaZpIcMQy5FPJ8GnvlfkM1KjkgS9iGFDLTtVkp2g%2Fl2LdK0860mdXvxoNUSdYUiFlGome3uXuGimuh4H3K6clq1JDXsGW5AiklVAZ%2FA0I8C%2Bj%2FfUuUquNLl0KS8Pnu7JXhRsXIloIp4TeZl%2BAH%2F1mW5cEicictMAf2md%2B3gvv9LCOeKttDY6iV2HCAqhR8FOvtud38FqMThK2Xyv6XWz5x4tpONdRTIh3Ryk9Og1WJdHKAJ5fDOZsNrwXCHIZ%2Fq779IZe%2BCoawiziY0s8SWizi%2Bfrp8xENt52KcXtaFubLLLA3eFq8HfPjeLg55NvXXwQZrV0W%2Frky3k57X0nbtdY6pwQEph6UxxvJ4yuScX4nTz%2Bim2YHXi7Fk7WvjNdBodWm5vjAJoCA6mIkUgVCpKqQfMLp7vH4B%2Bx9HjE0fkbsf6Ts76oyIT5ldG1Okf%2BxddbaopyfQvvpyavkHDOnp0JPRXskU%2Fqc5A2jCbbaN0yyj684nNqYvSO333EQ0tjYO9S10uUN6DomGVLhmNDRMIjJ%2BsUGOqUBRgF9q5%2FwNfhS4Iok9hSQDtrGnpswQ4CVqFLfr%2BgU4%2BX3102JLQHnKzapebdhaaLz%2FF3oXMqqXCYRKk1QOy8bmSgK8gOUiqYCrNlQD%2BY1wIERlgewO4c%2FctKikIN0laIJQ5sWVwLPa1nchtuKXcjzDjEg8Zg1s6m1GCzFZL3JvMslAz4hAkkw%2BNmQ0r7A46OLZANLwTFy5r38cMwCWGnlOtpHvI63&X-Amz-Signature=28d7f240c1b61a675c3c5184965fb86a637afbf5dfb7495bc1efde19c899553d&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject

152 | 153 |

sample caption

154 | 155 |

https://prod-files-secure.s3.us-west-2.amazonaws.com/4783548e-2442-4bf3-bb3d-ed4ddd2dcdf0/cb172387-5605-4385-80b7-28bfe98b1d74/sample_file.zip?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=ASIAZI2LB4662BTHUMP6%2F20250908%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Date=20250908T102656Z&X-Amz-Expires=3600&X-Amz-Security-Token=IQoJb3JpZ2luX2VjEFIaCXVzLXdlc3QtMiJHMEUCIQClISjXW088YSbVXYqxQOidsPLwjDpcj17GKYE7pJtj%2FQIgOqO9VQetih0Js%2BNpxW1DKD2GqG2ovLx1ehqXEraNpiMqiAQIu%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FARAAGgw2Mzc0MjMxODM4MDUiDAA%2F5WP8ZffIEpjPuyrcA5nhH9CGT4d9s44fyZxmFIa4cdBeN91EeaKf1wdWWOmOoPfMzPF1%2FslfHJphsWsphijKGU46mjku6DxRBFqxi5DrK4UtuNAGnGzJfgUfgMOaZpIcMQy5FPJ8GnvlfkM1KjkgS9iGFDLTtVkp2g%2Fl2LdK0860mdXvxoNUSdYUiFlGome3uXuGimuh4H3K6clq1JDXsGW5AiklVAZ%2FA0I8C%2Bj%2FfUuUquNLl0KS8Pnu7JXhRsXIloIp4TeZl%2BAH%2F1mW5cEicictMAf2md%2B3gvv9LCOeKttDY6iV2HCAqhR8FOvtud38FqMThK2Xyv6XWz5x4tpONdRTIh3Ryk9Og1WJdHKAJ5fDOZsNrwXCHIZ%2Fq779IZe%2BCoawiziY0s8SWizi%2Bfrp8xENt52KcXtaFubLLLA3eFq8HfPjeLg55NvXXwQZrV0W%2Frky3k57X0nbtdY6pwQEph6UxxvJ4yuScX4nTz%2Bim2YHXi7Fk7WvjNdBodWm5vjAJoCA6mIkUgVCpKqQfMLp7vH4B%2Bx9HjE0fkbsf6Ts76oyIT5ldG1Okf%2BxddbaopyfQvvpyavkHDOnp0JPRXskU%2Fqc5A2jCbbaN0yyj684nNqYvSO333EQ0tjYO9S10uUN6DomGVLhmNDRMIjJ%2BsUGOqUBRgF9q5%2FwNfhS4Iok9hSQDtrGnpswQ4CVqFLfr%2BgU4%2BX3102JLQHnKzapebdhaaLz%2FF3oXMqqXCYRKk1QOy8bmSgK8gOUiqYCrNlQD%2BY1wIERlgewO4c%2FctKikIN0laIJQ5sWVwLPa1nchtuKXcjzDjEg8Zg1s6m1GCzFZL3JvMslAz4hAkkw%2BNmQ0r7A46OLZANLwTFy5r38cMwCWGnlOtpHvI63&X-Amz-Signature=3509c189b09cd27ceae53592440ccb9d25e7ef15fab42ed2093f74497ed4db62&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject

156 | 157 |

PDF

158 | 159 |

https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf

160 | 161 |

dummy caption

162 | 163 |

https://prod-files-secure.s3.us-west-2.amazonaws.com/4783548e-2442-4bf3-bb3d-ed4ddd2dcdf0/a611f8cd-657f-44c9-a9d6-381c408aa0ea/dummy.pdf?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=ASIAZI2LB4662BTHUMP6%2F20250908%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Date=20250908T102656Z&X-Amz-Expires=3600&X-Amz-Security-Token=IQoJb3JpZ2luX2VjEFIaCXVzLXdlc3QtMiJHMEUCIQClISjXW088YSbVXYqxQOidsPLwjDpcj17GKYE7pJtj%2FQIgOqO9VQetih0Js%2BNpxW1DKD2GqG2ovLx1ehqXEraNpiMqiAQIu%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FARAAGgw2Mzc0MjMxODM4MDUiDAA%2F5WP8ZffIEpjPuyrcA5nhH9CGT4d9s44fyZxmFIa4cdBeN91EeaKf1wdWWOmOoPfMzPF1%2FslfHJphsWsphijKGU46mjku6DxRBFqxi5DrK4UtuNAGnGzJfgUfgMOaZpIcMQy5FPJ8GnvlfkM1KjkgS9iGFDLTtVkp2g%2Fl2LdK0860mdXvxoNUSdYUiFlGome3uXuGimuh4H3K6clq1JDXsGW5AiklVAZ%2FA0I8C%2Bj%2FfUuUquNLl0KS8Pnu7JXhRsXIloIp4TeZl%2BAH%2F1mW5cEicictMAf2md%2B3gvv9LCOeKttDY6iV2HCAqhR8FOvtud38FqMThK2Xyv6XWz5x4tpONdRTIh3Ryk9Og1WJdHKAJ5fDOZsNrwXCHIZ%2Fq779IZe%2BCoawiziY0s8SWizi%2Bfrp8xENt52KcXtaFubLLLA3eFq8HfPjeLg55NvXXwQZrV0W%2Frky3k57X0nbtdY6pwQEph6UxxvJ4yuScX4nTz%2Bim2YHXi7Fk7WvjNdBodWm5vjAJoCA6mIkUgVCpKqQfMLp7vH4B%2Bx9HjE0fkbsf6Ts76oyIT5ldG1Okf%2BxddbaopyfQvvpyavkHDOnp0JPRXskU%2Fqc5A2jCbbaN0yyj684nNqYvSO333EQ0tjYO9S10uUN6DomGVLhmNDRMIjJ%2BsUGOqUBRgF9q5%2FwNfhS4Iok9hSQDtrGnpswQ4CVqFLfr%2BgU4%2BX3102JLQHnKzapebdhaaLz%2FF3oXMqqXCYRKk1QOy8bmSgK8gOUiqYCrNlQD%2BY1wIERlgewO4c%2FctKikIN0laIJQ5sWVwLPa1nchtuKXcjzDjEg8Zg1s6m1GCzFZL3JvMslAz4hAkkw%2BNmQ0r7A46OLZANLwTFy5r38cMwCWGnlOtpHvI63&X-Amz-Signature=38fc0e2aaf99a08d5b2fd551f77311a551c797e2273c6857fa0e008695e870d0&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject

164 | 165 |

Videos

166 | 167 |

https://prod-files-secure.s3.us-west-2.amazonaws.com/4783548e-2442-4bf3-bb3d-ed4ddd2dcdf0/240102e7-3354-498f-9490-2dc9ad59e7dd/file_example_MP4_480_1_5MG.mp4?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=ASIAZI2LB4662BTHUMP6%2F20250908%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Date=20250908T102656Z&X-Amz-Expires=3600&X-Amz-Security-Token=IQoJb3JpZ2luX2VjEFIaCXVzLXdlc3QtMiJHMEUCIQClISjXW088YSbVXYqxQOidsPLwjDpcj17GKYE7pJtj%2FQIgOqO9VQetih0Js%2BNpxW1DKD2GqG2ovLx1ehqXEraNpiMqiAQIu%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FARAAGgw2Mzc0MjMxODM4MDUiDAA%2F5WP8ZffIEpjPuyrcA5nhH9CGT4d9s44fyZxmFIa4cdBeN91EeaKf1wdWWOmOoPfMzPF1%2FslfHJphsWsphijKGU46mjku6DxRBFqxi5DrK4UtuNAGnGzJfgUfgMOaZpIcMQy5FPJ8GnvlfkM1KjkgS9iGFDLTtVkp2g%2Fl2LdK0860mdXvxoNUSdYUiFlGome3uXuGimuh4H3K6clq1JDXsGW5AiklVAZ%2FA0I8C%2Bj%2FfUuUquNLl0KS8Pnu7JXhRsXIloIp4TeZl%2BAH%2F1mW5cEicictMAf2md%2B3gvv9LCOeKttDY6iV2HCAqhR8FOvtud38FqMThK2Xyv6XWz5x4tpONdRTIh3Ryk9Og1WJdHKAJ5fDOZsNrwXCHIZ%2Fq779IZe%2BCoawiziY0s8SWizi%2Bfrp8xENt52KcXtaFubLLLA3eFq8HfPjeLg55NvXXwQZrV0W%2Frky3k57X0nbtdY6pwQEph6UxxvJ4yuScX4nTz%2Bim2YHXi7Fk7WvjNdBodWm5vjAJoCA6mIkUgVCpKqQfMLp7vH4B%2Bx9HjE0fkbsf6Ts76oyIT5ldG1Okf%2BxddbaopyfQvvpyavkHDOnp0JPRXskU%2Fqc5A2jCbbaN0yyj684nNqYvSO333EQ0tjYO9S10uUN6DomGVLhmNDRMIjJ%2BsUGOqUBRgF9q5%2FwNfhS4Iok9hSQDtrGnpswQ4CVqFLfr%2BgU4%2BX3102JLQHnKzapebdhaaLz%2FF3oXMqqXCYRKk1QOy8bmSgK8gOUiqYCrNlQD%2BY1wIERlgewO4c%2FctKikIN0laIJQ5sWVwLPa1nchtuKXcjzDjEg8Zg1s6m1GCzFZL3JvMslAz4hAkkw%2BNmQ0r7A46OLZANLwTFy5r38cMwCWGnlOtpHvI63&X-Amz-Signature=0794da4a893d35577b444103b397df343656772fd3d70ccf7d0b3afb69faf766&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject

168 | 169 |

internal video sample

170 | 171 |

https://www.youtube.com/watch?v=V2PRhxphH2w

172 | 173 |

external video sample

174 | 175 |

Equations

176 | 177 | \[e=mc^2\] 178 | 179 |

Mentions

180 | 181 |


182 | 183 | --------------------------------------------------------------------------------