├── .ruby-version ├── spec ├── fixtures │ ├── file.txt │ ├── ssg │ │ ├── hugo │ │ │ ├── yaml │ │ │ │ └── config.yaml │ │ │ ├── toml │ │ │ │ └── config.toml │ │ │ └── json │ │ │ │ └── config.json │ │ ├── middleman │ │ │ └── Gemfile │ │ └── hexo │ │ │ └── package.json │ ├── image │ ├── image.gif │ ├── image.jpg │ ├── image.png │ ├── blurhash.jpg │ ├── dato-logo.jpg │ └── config.rb ├── dato_spec.rb ├── dato │ ├── dump │ │ ├── format │ │ │ └── yaml_spec.rb │ │ ├── ssg_detector_spec.rb │ │ └── runner_spec.rb │ ├── local │ │ ├── loader_spec.rb │ │ ├── field_type │ │ │ ├── lat_lon_spec.rb │ │ │ ├── color_spec.rb │ │ │ ├── upload_id_spec.rb │ │ │ ├── video_spec.rb │ │ │ └── seo_spec.rb │ │ ├── entities_repo_spec.rb │ │ ├── items_repo_integration_spec.rb │ │ ├── json_api_entity_spec.rb │ │ └── item_spec.rb │ ├── utils │ │ ├── meta_tags │ │ │ ├── og_locale_spec.rb │ │ │ ├── robots_spec.rb │ │ │ ├── article_modified_time_spec.rb │ │ │ ├── og_type_spec.rb │ │ │ ├── og_site_name_spec.rb │ │ │ ├── twitter_site_spec.rb │ │ │ ├── article_publisher_spec.rb │ │ │ ├── twitter_card_spec.rb │ │ │ ├── description_spec.rb │ │ │ ├── image_spec.rb │ │ │ └── title_spec.rb │ │ ├── seo_tags_builder_spec.rb │ │ ├── build_modular_block_spec.rb │ │ └── favicon_tags_builder_spec.rb │ ├── cli_spec.rb │ ├── json_api_serializer_spec.rb │ ├── account │ │ └── client_spec.rb │ └── upload │ │ └── file_spec.rb ├── support │ ├── account_client_provider.rb │ └── shared_contexts │ │ ├── new_site.rb │ │ └── meta_tags.rb └── spec_helper.rb ├── .rspec ├── lib ├── dato │ ├── version.rb │ ├── upload │ │ ├── image.rb │ │ ├── file.rb │ │ └── create_upload_path.rb │ ├── account │ │ └── client.rb │ ├── local │ │ ├── field_type │ │ │ ├── slug.rb │ │ │ ├── text.rb │ │ │ ├── float.rb │ │ │ ├── string.rb │ │ │ ├── boolean.rb │ │ │ ├── integer.rb │ │ │ ├── link.rb │ │ │ ├── date.rb │ │ │ ├── json.rb │ │ │ ├── single_block.rb │ │ │ ├── date_time.rb │ │ │ ├── links.rb │ │ │ ├── rich_text.rb │ │ │ ├── gallery.rb │ │ │ ├── lat_lon.rb │ │ │ ├── seo.rb │ │ │ ├── theme.rb │ │ │ ├── global_seo.rb │ │ │ ├── color.rb │ │ │ ├── upload_id.rb │ │ │ ├── structured_text.rb │ │ │ ├── video.rb │ │ │ └── file.rb │ │ ├── json_api_meta.rb │ │ ├── entities_repo.rb │ │ ├── site.rb │ │ ├── json_api_entity.rb │ │ ├── item.rb │ │ ├── loader.rb │ │ └── items_repo.rb │ ├── utils │ │ ├── meta_tags │ │ │ ├── robots.rb │ │ │ ├── og_locale.rb │ │ │ ├── twitter_card.rb │ │ │ ├── article_modified_time.rb │ │ │ ├── og_type.rb │ │ │ ├── og_site_name.rb │ │ │ ├── twitter_site.rb │ │ │ ├── article_publisher.rb │ │ │ ├── description.rb │ │ │ ├── image.rb │ │ │ ├── title.rb │ │ │ └── base.rb │ │ ├── locale_value.rb │ │ ├── build_modular_block.rb │ │ ├── seo_tags_builder.rb │ │ └── favicon_tags_builder.rb │ ├── dump │ │ ├── dsl │ │ │ ├── add_to_data_file.rb │ │ │ ├── create_data_file.rb │ │ │ ├── directory.rb │ │ │ ├── create_post.rb │ │ │ └── root.rb │ │ ├── format │ │ │ ├── json.rb │ │ │ ├── toml.rb │ │ │ └── yaml.rb │ │ ├── operation │ │ │ ├── root.rb │ │ │ ├── create_data_file.rb │ │ │ ├── directory.rb │ │ │ ├── create_post.rb │ │ │ └── add_to_data_file.rb │ │ ├── format.rb │ │ ├── runner.rb │ │ └── ssg_detector.rb │ ├── api_error.rb │ ├── paginator.rb │ ├── site │ │ └── client.rb │ ├── json_schema_type.rb │ ├── json_schema_relationships.rb │ ├── json_api_deserializer.rb │ ├── cli.rb │ ├── repo.rb │ ├── json_api_serializer.rb │ └── api_client.rb └── dato.rb ├── Gemfile ├── bin ├── setup ├── console └── rspec ├── .gitignore ├── .travis.yml ├── exe └── dato ├── Rakefile ├── TODO.md ├── LICENSE ├── LICENSE.txt ├── .rubocop.yml ├── dato.gemspec ├── CODE_OF_CONDUCT.md ├── CHANGELOG.md └── README.md /.ruby-version: -------------------------------------------------------------------------------- 1 | 2.7.5 2 | -------------------------------------------------------------------------------- /spec/fixtures/file.txt: -------------------------------------------------------------------------------- 1 | Hi there! 2 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format progress 2 | --color 3 | -------------------------------------------------------------------------------- /spec/fixtures/ssg/hugo/yaml/config.yaml: -------------------------------------------------------------------------------- 1 | baseurl: http://google.com/ 2 | -------------------------------------------------------------------------------- /spec/fixtures/ssg/hugo/toml/config.toml: -------------------------------------------------------------------------------- 1 | baseurl = "http://google.com/" 2 | -------------------------------------------------------------------------------- /spec/fixtures/ssg/hugo/json/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseurl": "http://google.com/" 3 | } 4 | -------------------------------------------------------------------------------- /spec/fixtures/image: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datocms/ruby-datocms-client/HEAD/spec/fixtures/image -------------------------------------------------------------------------------- /lib/dato/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dato 4 | VERSION = "0.8.3" 5 | end 6 | -------------------------------------------------------------------------------- /spec/fixtures/image.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datocms/ruby-datocms-client/HEAD/spec/fixtures/image.gif -------------------------------------------------------------------------------- /spec/fixtures/image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datocms/ruby-datocms-client/HEAD/spec/fixtures/image.jpg -------------------------------------------------------------------------------- /spec/fixtures/image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datocms/ruby-datocms-client/HEAD/spec/fixtures/image.png -------------------------------------------------------------------------------- /spec/fixtures/blurhash.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datocms/ruby-datocms-client/HEAD/spec/fixtures/blurhash.jpg -------------------------------------------------------------------------------- /spec/fixtures/dato-logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datocms/ruby-datocms-client/HEAD/spec/fixtures/dato-logo.jpg -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | # Specify your gem's dependencies in dato.gemspec 6 | gemspec 7 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /lib/dato/upload/image.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "dato/upload/file" 4 | 5 | module Dato 6 | module Upload 7 | Image = Dato::Upload::File 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /coverage/ 10 | /tmp/ 11 | build.zip 12 | .env 13 | dato.config.rb 14 | -------------------------------------------------------------------------------- /spec/dato_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | describe Dato do 6 | it "has a version number" do 7 | expect(Dato::VERSION).not_to be nil 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.6.0 4 | - 2.7.5 5 | before_install: 6 | - gem install bundler -v 1.13.5 7 | - echo 'puts "ruby \"#{RUBY_VERSION}\""' | ruby >> Gemfile 8 | - cat Gemfile 9 | script: rubocop && rake -------------------------------------------------------------------------------- /exe/dato: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | $LOAD_PATH.unshift File.expand_path("../lib", __dir__) 5 | 6 | require "dotenv" 7 | Dotenv.load 8 | 9 | require "dato" 10 | Dato::Cli.start(ARGV) 11 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "rspec/core/rake_task" 5 | 6 | RSpec::Core::RakeTask.new(:spec) do |t| 7 | t.rspec_opts = "-b" 8 | end 9 | 10 | task default: :spec 11 | -------------------------------------------------------------------------------- /lib/dato/account/client.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "dato/api_client" 4 | 5 | module Dato 6 | module Account 7 | class Client 8 | include ApiClient 9 | 10 | json_schema "account-api" 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/dato/local/field_type/slug.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dato 4 | module Local 5 | module FieldType 6 | class Slug 7 | def self.parse(value, _repo) 8 | value 9 | end 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/dato/local/field_type/text.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dato 4 | module Local 5 | module FieldType 6 | class Text 7 | def self.parse(value, _repo) 8 | value 9 | end 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/fixtures/ssg/middleman/Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "middleman" 6 | gem "middleman-autoprefixer" 7 | gem "middleman-dato", "0.5.14" 8 | gem "middleman-livereload" 9 | gem "middleman-search_engine_sitemap" 10 | -------------------------------------------------------------------------------- /lib/dato/local/field_type/float.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dato 4 | module Local 5 | module FieldType 6 | class Float 7 | def self.parse(value, _repo) 8 | value 9 | end 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/dato/local/field_type/string.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dato 4 | module Local 5 | module FieldType 6 | class String 7 | def self.parse(value, _repo) 8 | value 9 | end 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/dato/local/field_type/boolean.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dato 4 | module Local 5 | module FieldType 6 | class Boolean 7 | def self.parse(value, _repo) 8 | value 9 | end 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/dato/local/field_type/integer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dato 4 | module Local 5 | module FieldType 6 | class Integer 7 | def self.parse(value, _repo) 8 | value 9 | end 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/dato/local/field_type/link.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dato 4 | module Local 5 | module FieldType 6 | class Link 7 | def self.parse(value, repo) 8 | value && repo.find(value) 9 | end 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/dato/local/field_type/date.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dato 4 | module Local 5 | module FieldType 6 | class Date 7 | def self.parse(value, _repo) 8 | value && ::Date.parse(value) 9 | end 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/dato/local/field_type/json.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dato 4 | module Local 5 | module FieldType 6 | class Json 7 | def self.parse(value, _repo) 8 | value && JSON.parse(value) 9 | end 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/dato/local/field_type/single_block.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dato 4 | module Local 5 | module FieldType 6 | class SingleBlock 7 | def self.parse(value, repo) 8 | value && repo.find(value) 9 | end 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/dato/local/field_type/date_time.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dato 4 | module Local 5 | module FieldType 6 | class DateTime 7 | def self.parse(value, _repo) 8 | value && ::Time.parse(value).utc 9 | end 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/fixtures/ssg/hexo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "test", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "", 10 | "license": "ISC", 11 | "dependencies": { 12 | "hexo": "3.2.2" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /lib/dato/utils/meta_tags/robots.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "dato/utils/meta_tags/base" 4 | 5 | module Dato 6 | module Utils 7 | module MetaTags 8 | class Robots < Base 9 | def build 10 | meta_tag("robots", "noindex") if site.no_index 11 | end 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/dato/dump/dsl/add_to_data_file.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "dato/dump/operation/add_to_data_file" 4 | 5 | module Dato 6 | module Dump 7 | module Dsl 8 | module AddToDataFile 9 | def add_to_data_file(*args) 10 | operations.add Operation::AddToDataFile.new(operations, *args) 11 | end 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/dato/dump/dsl/create_data_file.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "dato/dump/operation/create_data_file" 4 | 5 | module Dato 6 | module Dump 7 | module Dsl 8 | module CreateDataFile 9 | def create_data_file(*args) 10 | operations.add Operation::CreateDataFile.new(operations, *args) 11 | end 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/dato/dump/format/json.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "json" 4 | 5 | module Dato 6 | module Dump 7 | module Format 8 | module Json 9 | def self.dump(value) 10 | JSON.dump(value) 11 | end 12 | 13 | def self.frontmatter_dump(value) 14 | "#{dump(value)}\n" 15 | end 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/dato/utils/meta_tags/og_locale.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "dato/utils/meta_tags/base" 4 | 5 | module Dato 6 | module Utils 7 | module MetaTags 8 | class OgLocale < Base 9 | def build 10 | locale = I18n.locale 11 | og_tag("og:locale", "#{locale}_#{locale.upcase}") 12 | end 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/dato/utils/meta_tags/twitter_card.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "dato/utils/meta_tags/base" 4 | 5 | module Dato 6 | module Utils 7 | module MetaTags 8 | class TwitterCard < Base 9 | def build 10 | card_tag("twitter:card", seo_field_with_fallback(:twitter_card, nil) || "summary") 11 | end 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/dato.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_support/all" 4 | 5 | require "dato/version" 6 | 7 | require "dato/site/client" 8 | require "dato/account/client" 9 | require "dato/local/site" 10 | require "dato/cli" 11 | require "dato/utils/seo_tags_builder" 12 | require "dato/utils/favicon_tags_builder" 13 | require "dato/utils/build_modular_block" 14 | 15 | module Dato 16 | end 17 | -------------------------------------------------------------------------------- /lib/dato/utils/meta_tags/article_modified_time.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "dato/utils/meta_tags/base" 4 | require "time" 5 | 6 | module Dato 7 | module Utils 8 | module MetaTags 9 | class ArticleModifiedTime < Base 10 | def build 11 | og_tag("article:modified_time", item.updated_at.iso8601) if item 12 | end 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/setup" 5 | require "dato" 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | # (If you use this, don't forget to add pry to your Gemfile!) 11 | # require "pry" 12 | # Pry.start 13 | 14 | require "irb" 15 | IRB.start 16 | -------------------------------------------------------------------------------- /lib/dato/utils/locale_value.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "i18n/backend/fallbacks" 4 | 5 | module Dato 6 | module Utils 7 | module LocaleValue 8 | def self.find(obj) 9 | locale_with_value = I18n.fallbacks[I18n.locale] 10 | .find { |locale| obj[locale] } 11 | 12 | obj[locale_with_value || I18n.locale] 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/dato/dump/format/yaml_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | module Dato 6 | module Dump 7 | module Format 8 | RSpec.describe Yaml do 9 | describe ".dump" do 10 | it "dumps into Yaml stringifying symbols" do 11 | expect(Yaml.dump([{ foo: "bar" }])).to eq "- foo: bar" 12 | end 13 | end 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/dato/utils/meta_tags/og_type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "dato/utils/meta_tags/base" 4 | 5 | module Dato 6 | module Utils 7 | module MetaTags 8 | class OgType < Base 9 | def build 10 | if !item || item.singleton? 11 | og_tag("og:type", "website") 12 | else 13 | og_tag("og:type", "article") 14 | end 15 | end 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/dato/utils/meta_tags/og_site_name.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "dato/utils/meta_tags/base" 4 | 5 | module Dato 6 | module Utils 7 | module MetaTags 8 | class OgSiteName < Base 9 | def build 10 | og_tag("og:site_name", site_name) if site_name 11 | end 12 | 13 | def site_name 14 | site.global_seo && site.global_seo.site_name 15 | end 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/dato/utils/meta_tags/twitter_site.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "dato/utils/meta_tags/base" 4 | 5 | module Dato 6 | module Utils 7 | module MetaTags 8 | class TwitterSite < Base 9 | def build 10 | card_tag("twitter:site", twitter_account) if twitter_account 11 | end 12 | 13 | def twitter_account 14 | site.global_seo && site.global_seo.twitter_account 15 | end 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/dato/utils/meta_tags/article_publisher.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "dato/utils/meta_tags/base" 4 | 5 | module Dato 6 | module Utils 7 | module MetaTags 8 | class ArticlePublisher < Base 9 | def build 10 | og_tag("article:publisher", facebook_page_url) if facebook_page_url 11 | end 12 | 13 | def facebook_page_url 14 | site.global_seo && site.global_seo.facebook_page_url 15 | end 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/dato/api_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dato 4 | class ApiError < StandardError 5 | attr_reader :response, :body 6 | 7 | def initialize(response) 8 | body = JSON.parse(response[:body]) if response[:body] 9 | 10 | message = [ 11 | "DatoCMS API Error", 12 | "Status: #{response[:status]}", 13 | "Response:", 14 | JSON.pretty_generate(body), 15 | ].join("\n") 16 | 17 | super(message) 18 | 19 | @response = response 20 | @body = body 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/dato/dump/operation/root.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dato 4 | module Dump 5 | module Operation 6 | class Root 7 | attr_reader :path 8 | 9 | def initialize(path) 10 | @operations = [] 11 | @path = path 12 | end 13 | 14 | def add(operation) 15 | @operations << operation 16 | end 17 | 18 | def perform 19 | operations.each(&:perform) 20 | end 21 | 22 | private 23 | 24 | attr_reader :operations 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/dato/local/field_type/links.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dato 4 | module Local 5 | module FieldType 6 | class Links < Array 7 | def self.parse(ids, repo) 8 | items = if ids 9 | ids.map { |id| repo.find(id) } 10 | else 11 | [] 12 | end 13 | new(items) 14 | end 15 | 16 | def to_hash(max_depth = 3, current_depth = 0) 17 | map { |item| item.to_hash(max_depth, current_depth) } 18 | end 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/dato/local/field_type/rich_text.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dato 4 | module Local 5 | module FieldType 6 | class RichText < Array 7 | def self.parse(ids, repo) 8 | items = if ids 9 | ids.map { |id| repo.find(id) } 10 | else 11 | [] 12 | end 13 | new(items) 14 | end 15 | 16 | def to_hash(max_depth = 3, current_depth = 0) 17 | map { |item| item.to_hash(max_depth, current_depth) } 18 | end 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /spec/dato/local/loader_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | describe Dato::Local::Loader, :vcr do 6 | include_context "with a new site" 7 | 8 | subject(:loader) do 9 | described_class.new(client, true) 10 | end 11 | 12 | it "fetches an entire site" do 13 | loader.load 14 | repo = loader.items_repo 15 | 16 | expect(repo.articles.size).to eq 1 17 | expect(repo.articles.first.title).to eq "First post" 18 | expect(repo.articles.first.image.format).to eq "png" 19 | expect(repo.articles.first.file.format).to eq "txt" 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/dato/utils/meta_tags/og_locale_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | module Dato 6 | module Utils 7 | module MetaTags 8 | describe OgLocale do 9 | include_context "items repo" 10 | 11 | subject(:builder) { described_class.new(item, site) } 12 | 13 | describe "#build" do 14 | it "returns current i18n locale" do 15 | result = I18n.with_locale(:en) { builder.build } 16 | expect(result[:attributes][:content]).to eq("en_EN") 17 | end 18 | end 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /spec/dato/utils/seo_tags_builder_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | module Dato 6 | module Utils 7 | describe SeoTagsBuilder do 8 | include_context "items repo" 9 | 10 | subject(:builder) { described_class.new(item, site) } 11 | 12 | describe "#meta_tags" do 13 | it "returns an array of tags" do 14 | expect(builder.meta_tags).to be_an Array 15 | expect(builder.meta_tags.first).to be_an Hash 16 | expect(builder.meta_tags.first[:tag_name]).to eq "meta" 17 | end 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/dato/dump/format/toml.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "toml" 4 | 5 | class Time 6 | def to_toml(_path = "") 7 | utc.strftime("%Y-%m-%dT%H:%M:%SZ") 8 | end 9 | end 10 | 11 | class Date 12 | def to_toml(_path = "") 13 | strftime("%Y-%m-%d") 14 | end 15 | end 16 | 17 | module Dato 18 | module Dump 19 | module Format 20 | module Toml 21 | def self.dump(value) 22 | TOML::Generator.new(value).body 23 | end 24 | 25 | def self.frontmatter_dump(value) 26 | "+++\n#{dump(value)}+++" 27 | end 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /spec/dato/local/field_type/lat_lon_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | module Dato 6 | module Local 7 | module FieldType 8 | RSpec.describe LatLon do 9 | subject(:latlon) { described_class.parse(attributes, nil) } 10 | let(:attributes) do 11 | { 12 | latitude: 12, 13 | longitude: 10, 14 | } 15 | end 16 | 17 | it "responds to latitude and longitude methods" do 18 | expect(latlon.latitude).to eq 12 19 | expect(latlon.longitude).to eq 10 20 | end 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /spec/fixtures/config.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | def helper_method_example 4 | puts "A helper method" 5 | end 6 | 7 | dato.available_locales.each do |_locale| 8 | create_data_file "site.yml", :yml, dato.site.to_hash 9 | 10 | directory "posts" do 11 | helper_method_example 12 | 13 | dato.articles.each do |post| 14 | create_post "#{post.slug}.md" do 15 | frontmatter :yaml, post.to_hash.merge!(image_url_with_focal_point: post.image.url(w: 150, h: 50, fit: :crop)) 16 | content post.title 17 | end 18 | end 19 | end 20 | 21 | add_to_data_file "foobar.toml", :toml, sitename: dato.site.name 22 | end 23 | -------------------------------------------------------------------------------- /lib/dato/local/field_type/gallery.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "dato/local/field_type/file" 4 | 5 | module Dato 6 | module Local 7 | module FieldType 8 | class Gallery < Array 9 | def self.parse(value, repo) 10 | images = if value 11 | value.map { |image| FieldType::File.parse(image, repo) } 12 | else 13 | [] 14 | end 15 | new(images) 16 | end 17 | 18 | def to_hash(max_depth = 3, current_depth = 0) 19 | map { |item| item.to_hash(max_depth, current_depth) } 20 | end 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/dato/utils/meta_tags/description.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "dato/utils/meta_tags/base" 4 | 5 | module Dato 6 | module Utils 7 | module MetaTags 8 | class Description < Base 9 | def build 10 | return unless description.present? 11 | 12 | [ 13 | meta_tag("description", description), 14 | og_tag("og:description", description), 15 | card_tag("twitter:description", description), 16 | ] 17 | end 18 | 19 | def description 20 | @description ||= seo_field_with_fallback(:description, nil) 21 | end 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/dato/dump/format.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "dato/dump/format/toml" 4 | require "dato/dump/format/yaml" 5 | require "dato/dump/format/json" 6 | 7 | module Dato 8 | module Dump 9 | module Format 10 | def self.dump(format, value) 11 | converter_for(format).dump(value) 12 | end 13 | 14 | def self.frontmatter_dump(format, value) 15 | converter_for(format).frontmatter_dump(value) 16 | end 17 | 18 | def self.converter_for(format) 19 | case format.to_sym 20 | when :toml 21 | Format::Toml 22 | when :yaml, :yml 23 | Format::Yaml 24 | when :json 25 | Format::Json 26 | end 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/dato/local/field_type/lat_lon.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dato 4 | module Local 5 | module FieldType 6 | class LatLon 7 | attr_reader :latitude, :longitude 8 | 9 | def self.parse(value, _repo) 10 | value && new(value[:latitude], value[:longitude]) 11 | end 12 | 13 | def initialize(latitude, longitude) 14 | @latitude = latitude 15 | @longitude = longitude 16 | end 17 | 18 | def values 19 | [latitude, longitude] 20 | end 21 | 22 | def to_hash(*_args) 23 | { 24 | latitude: latitude, 25 | longitude: longitude, 26 | } 27 | end 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/dato/dump/operation/create_data_file.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "fileutils" 4 | require "dato/dump/format" 5 | 6 | module Dato 7 | module Dump 8 | module Operation 9 | class CreateDataFile 10 | attr_reader :context, :path, :format, :value 11 | 12 | def initialize(context, path, format, value) 13 | @context = context 14 | @path = path 15 | @format = format 16 | @value = value 17 | end 18 | 19 | def perform 20 | FileUtils.mkdir_p(File.dirname(path)) 21 | 22 | File.open(File.join(context.path, path), "w") do |file| 23 | file.write Format.dump(format, value) 24 | end 25 | end 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/dato/dump/format/yaml.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "yaml" 4 | 5 | class Array 6 | def deep_stringify_keys 7 | each_with_object([]) do |value, accum| 8 | if value.is_a?(Hash) || value.is_a?(Array) 9 | new_val = value.deep_stringify_keys 10 | accum.push new_val 11 | else 12 | accum.push value 13 | end 14 | accum 15 | end 16 | end 17 | end 18 | 19 | module Dato 20 | module Dump 21 | module Format 22 | module Yaml 23 | def self.dump(value) 24 | YAML.dump(value.deep_stringify_keys).chomp.gsub(/^-+\n/, "") 25 | end 26 | 27 | def self.frontmatter_dump(value) 28 | "---\n#{dump(value)}\n---" 29 | end 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/dato/dump/operation/directory.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "fileutils" 4 | 5 | module Dato 6 | module Dump 7 | module Operation 8 | class Directory 9 | attr_reader :context, :path 10 | 11 | def initialize(context, path) 12 | @context = context 13 | @path = File.join(context.path, path) 14 | @operations = [] 15 | end 16 | 17 | def add(operation) 18 | @operations << operation 19 | end 20 | 21 | def perform 22 | FileUtils.remove_dir(path) if Dir.exist?(path) 23 | 24 | FileUtils.mkdir_p(path) 25 | 26 | operations.each(&:perform) 27 | end 28 | 29 | private 30 | 31 | attr_reader :operations 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | ```ruby 2 | StructuredTextRenderer.new( 3 | foo.content, 4 | adapter: Adapter.new( 5 | render_text: lambda do |text| 6 | text.gsub(/this/, "that") 7 | end, 8 | render_fragment: lambda do |children| 9 | children.join("") 10 | end, 11 | render_node: lambda do |tagname, attrs, children| 12 | # we could ActionView::Helpers::TagHelper 13 | content_tag(tagname, children, attrs) 14 | end, 15 | ) 16 | custom_rules: { 17 | heading: lambda do |node, children, adapter| 18 | adapter.render_node("h#{node[:level] + 1}", {}, children) 19 | end 20 | }, 21 | render_link_to_record: lambda do |record, children, adapter| 22 | end, 23 | render_inline_record: lambda do |record, adapter| 24 | end, 25 | render_block: lambda do |record, adapter| 26 | end 27 | ) 28 | ``` -------------------------------------------------------------------------------- /lib/dato/local/json_api_meta.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dato 4 | module Local 5 | class JsonApiMeta 6 | attr_reader :payload 7 | 8 | def initialize(payload) 9 | @payload = payload || {} 10 | end 11 | 12 | def [](key) 13 | @payload[key] 14 | end 15 | 16 | def respond_to_missing?(method, include_private = false) 17 | if @payload.key?(method) 18 | true 19 | else 20 | super 21 | end 22 | end 23 | 24 | private 25 | 26 | def method_missing(method, *arguments, &block) 27 | return super unless arguments.empty? 28 | 29 | if @payload.key?(method) 30 | @payload[method] 31 | else 32 | super 33 | end 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /spec/support/account_client_provider.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module AccountClientProvider 4 | def generate_account_client!(options = {}) 5 | random_string = (0...8).map { rand(65..90).chr }.join 6 | 7 | anonymous_client = Dato::Account::Client.new( 8 | nil, 9 | base_url: ENV.fetch("ACCOUNT_API_BASE_URL"), 10 | ) 11 | 12 | account = anonymous_client.account.create( 13 | email: "#{random_string}@delete-this-at-midnight-utc.tk", 14 | password: "veryst_9rong_passowrd4_", 15 | name: "Test", 16 | company: "DatoCMS", 17 | ) 18 | 19 | Dato::Account::Client.new( 20 | account[:id], 21 | options.merge( 22 | base_url: ENV.fetch("ACCOUNT_API_BASE_URL"), 23 | ), 24 | ) 25 | end 26 | end 27 | 28 | RSpec.configure do |_config| 29 | include AccountClientProvider 30 | end 31 | -------------------------------------------------------------------------------- /lib/dato/dump/dsl/directory.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "dato/dump/dsl/create_post" 4 | require "dato/dump/dsl/create_data_file" 5 | require "dato/dump/dsl/add_to_data_file" 6 | 7 | module Dato 8 | module Dump 9 | module Dsl 10 | class Directory 11 | include Dsl::CreateDataFile 12 | include Dsl::CreatePost 13 | include Dsl::AddToDataFile 14 | 15 | attr_reader :dato, :operations 16 | 17 | def initialize(dato, operations, &block) 18 | @dato = dato 19 | @operations = operations 20 | @self_before_instance_eval = eval "self", block.binding 21 | 22 | instance_eval(&block) 23 | end 24 | 25 | def method_missing(method, *args, &block) 26 | @self_before_instance_eval.send method, *args, &block 27 | end 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/dato/utils/build_modular_block.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "dato/json_api_serializer" 4 | 5 | module Dato 6 | module Utils 7 | module BuildModularBlock 8 | def self.build(unserialized_body) 9 | json_api_serializer = JsonApiSerializer.new(type: "item") 10 | attributes = json_api_serializer.serialized_attributes(unserialized_body) 11 | 12 | payload = { 13 | type: "item", 14 | attributes: attributes, 15 | relationships: { 16 | item_type: { 17 | data: { 18 | id: unserialized_body[:item_type], 19 | type: "item_type", 20 | }, 21 | }, 22 | }, 23 | } 24 | 25 | payload[:id] = unserialized_body[:id] if unserialized_body[:id] 26 | 27 | payload 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /spec/dato/local/field_type/color_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | module Dato 6 | module Local 7 | module FieldType 8 | RSpec.describe Dato::Local::FieldType::Color do 9 | subject(:file) { described_class.parse(attributes, nil) } 10 | let(:attributes) do 11 | { 12 | red: 255, 13 | green: 127, 14 | blue: 0, 15 | alpha: 255, 16 | } 17 | end 18 | 19 | it "responds to red, green, blue, hex and rgb methods" do 20 | expect(file.red).to eq 255 21 | expect(file.green).to eq 127 22 | expect(file.blue).to eq 0 23 | expect(file.alpha).to eq 1.0 24 | expect(file.rgb).to eq "rgb(255, 127, 0)" 25 | expect(file.hex).to eq "#ff7f00" 26 | end 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/dato/utils/meta_tags/robots_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | module Dato 6 | module Utils 7 | module MetaTags 8 | describe Robots do 9 | include_context "items repo" 10 | 11 | subject(:builder) { described_class.new(item, site) } 12 | 13 | describe "#build" do 14 | let(:result) { builder.build } 15 | 16 | context "with site noIndex set" do 17 | let(:no_index) { true } 18 | 19 | it "returns robots meta tag" do 20 | expect(result[:attributes][:content]).to eq("noindex") 21 | end 22 | end 23 | 24 | context "with site noIndex not set" do 25 | it "returns no tags" do 26 | expect(result).to be_nil 27 | end 28 | end 29 | end 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/dato/dump/dsl/create_post.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "dato/dump/operation/create_post" 4 | 5 | module Dato 6 | module Dump 7 | module Dsl 8 | class DataFile 9 | def initialize(operation, &block) 10 | @operation = operation 11 | instance_eval(&block) 12 | end 13 | 14 | def frontmatter(format, value) 15 | @operation.frontmatter_format = format 16 | @operation.frontmatter_value = value 17 | end 18 | 19 | def content(value) 20 | @operation.content = value 21 | end 22 | end 23 | 24 | module CreatePost 25 | def create_post(path, &block) 26 | operation = Operation::CreatePost.new(operations, path) 27 | DataFile.new(operation, &block) 28 | 29 | operations.add operation 30 | end 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/dato/dump/operation/create_post.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "fileutils" 4 | require "dato/dump/format" 5 | 6 | module Dato 7 | module Dump 8 | module Operation 9 | class CreatePost 10 | attr_reader :context, :path 11 | 12 | attr_accessor :frontmatter_format, :frontmatter_value, :content 13 | 14 | def initialize(context, path) 15 | @context = context 16 | @path = path 17 | end 18 | 19 | def perform 20 | FileUtils.mkdir_p(File.dirname(path)) 21 | 22 | File.open(File.join(context.path, path), "w") do |file| 23 | file.write Format.frontmatter_dump( 24 | frontmatter_format, 25 | frontmatter_value, 26 | ) 27 | file.write "\n\n" 28 | file.write content 29 | end 30 | end 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /spec/dato/utils/meta_tags/article_modified_time_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | module Dato 6 | module Utils 7 | module MetaTags 8 | describe ArticleModifiedTime do 9 | include_context "items repo" 10 | subject(:builder) { described_class.new(item, site) } 11 | 12 | describe "#build" do 13 | let(:result) { builder.build } 14 | 15 | context "with no item" do 16 | it "returns no tags" do 17 | expect(result).to be_nil 18 | end 19 | end 20 | 21 | context "with item" do 22 | let(:item) { items_repo.articles.first } 23 | 24 | it "returns iso 8601 datetime" do 25 | expect(result[:attributes][:content]).to eq("2016-12-07T09:14:22Z") 26 | end 27 | end 28 | end 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /spec/dato/utils/meta_tags/og_type_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | module Dato 6 | module Utils 7 | module MetaTags 8 | describe OgType do 9 | include_context "items repo" 10 | 11 | subject(:builder) { described_class.new(item, site) } 12 | 13 | describe "#build" do 14 | let(:result) { builder.build } 15 | 16 | context "with no item" do 17 | it "returns website og:type" do 18 | expect(result[:attributes][:content]).to eq("website") 19 | end 20 | end 21 | 22 | context "with item" do 23 | let(:item) { items_repo.articles.first } 24 | 25 | it "returns article og:type" do 26 | expect(result[:attributes][:content]).to eq("article") 27 | end 28 | end 29 | end 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/dato/upload/file.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "dato/upload/create_upload_path" 4 | 5 | module Dato 6 | module Upload 7 | class File 8 | attr_reader :client, :source, :upload_attributes, :field_attributes 9 | 10 | def initialize(client, source, upload_attributes = {}, field_attributes = {}) 11 | @client = client 12 | @source = source 13 | @upload_attributes = upload_attributes 14 | @field_attributes = field_attributes 15 | end 16 | 17 | def upload 18 | upload_path = CreateUploadPath.new(client, source).upload_path 19 | 20 | upload = client.uploads.create( 21 | upload_attributes.merge(path: upload_path), 22 | ) 23 | 24 | { 25 | alt: nil, 26 | title: nil, 27 | custom_data: {}, 28 | }.merge(field_attributes).merge(upload_id: upload["id"]) 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /bin/rspec: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'rspec' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "pathname" 12 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 13 | Pathname.new(__FILE__).realpath) 14 | 15 | bundle_binstub = File.expand_path("bundle", __dir__) 16 | 17 | if File.file?(bundle_binstub) 18 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 19 | load(bundle_binstub) 20 | else 21 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 22 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 23 | end 24 | end 25 | 26 | require "rubygems" 27 | require "bundler/setup" 28 | 29 | load Gem.bin_path("rspec-core", "rspec") 30 | -------------------------------------------------------------------------------- /lib/dato/paginator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dato 4 | class Paginator 5 | def initialize(client, base_endpoint, filters) 6 | @client = client 7 | @base_endpoint = base_endpoint 8 | @filters = filters 9 | end 10 | 11 | def response 12 | items_per_page = 100 13 | 14 | base_response = @client.get( 15 | @base_endpoint, @filters.dup.merge("page[limit]" => items_per_page) 16 | ) 17 | 18 | extra_pages = ( 19 | base_response[:meta][:total_count] / items_per_page.to_f 20 | ).ceil - 1 21 | 22 | extra_pages.times do |page| 23 | base_response[:data] += @client.get( 24 | @base_endpoint, 25 | @filters.dup.merge( 26 | "page[offset]" => items_per_page * (page + 1), 27 | "page[limit]" => items_per_page, 28 | ), 29 | )[:data] 30 | end 31 | 32 | base_response 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /spec/dato/utils/meta_tags/og_site_name_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | module Dato 6 | module Utils 7 | module MetaTags 8 | describe OgSiteName do 9 | include_context "items repo" 10 | 11 | subject(:builder) { described_class.new(item, site) } 12 | 13 | describe "#build" do 14 | let(:result) { builder.build } 15 | 16 | context "with site name not set" do 17 | it "returns no tags" do 18 | expect(result).to be_nil 19 | end 20 | end 21 | 22 | context "with site name set" do 23 | let(:global_seo) do 24 | { site_name: "My site" } 25 | end 26 | 27 | it "returns og:site_name tag" do 28 | expect(result[:attributes][:content]).to eq("My site") 29 | end 30 | end 31 | end 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /spec/dato/cli_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | module Dato 6 | describe Cli do 7 | let(:client) { double("Dato::Site::Client", get: site_data) } 8 | let(:site_data) { { "data" => { "id" => "id" } } } 9 | let(:runner) { instance_double(Dato::Dump::Runner, run: nil) } 10 | 11 | before do 12 | allow(Dato::Site::Client).to receive(:new) { client } 13 | allow(client).to receive_message_chain(:items, :all).and_return({}) 14 | allow(client).to receive_message_chain(:uploads, :all).and_return({}) 15 | allow(Dato::Dump::Runner) 16 | .to receive(:new).with(anything, anything, anything, anything) { runner } 17 | end 18 | 19 | describe "#dump" do 20 | context "in watch mode" do 21 | it "dumps data" do 22 | described_class.start(%w[dump --token sometoken]) 23 | expect(runner).to have_received(:run) 24 | end 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/dato/local/field_type/seo.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dato 4 | module Local 5 | module FieldType 6 | class Seo 7 | attr_reader :title, :description, :twitter_card 8 | 9 | def self.parse(value, repo) 10 | value && new(value[:title], value[:description], value[:image], value[:twitter_card], repo) 11 | end 12 | 13 | def initialize(title, description, image, twitter_card, repo) 14 | @title = title 15 | @description = description 16 | @image = image 17 | @repo = repo 18 | @twitter_card = twitter_card 19 | end 20 | 21 | def image 22 | @image && UploadId.parse(@image, @repo) 23 | end 24 | 25 | def to_hash(*args) 26 | { 27 | title: title, 28 | description: description, 29 | image: image && image.to_hash(*args), 30 | } 31 | end 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /spec/dato/utils/meta_tags/twitter_site_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | module Dato 6 | module Utils 7 | module MetaTags 8 | describe TwitterSite do 9 | include_context "items repo" 10 | 11 | subject(:builder) { described_class.new(item, site) } 12 | 13 | describe "#build" do 14 | let(:result) { builder.build } 15 | 16 | context "with twitter account not set" do 17 | it "returns no tags" do 18 | expect(result).to be_nil 19 | end 20 | end 21 | 22 | context "with twitter account set" do 23 | let(:global_seo) do 24 | { 25 | twitter_account: "@steffoz", 26 | } 27 | end 28 | 29 | it "returns robots meta tag" do 30 | expect(result[:attributes][:content]).to eq("@steffoz") 31 | end 32 | end 33 | end 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/dato/utils/meta_tags/image.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "dato/utils/meta_tags/base" 4 | 5 | module Dato 6 | module Utils 7 | module MetaTags 8 | class Image < Base 9 | def build 10 | return unless image 11 | 12 | [ 13 | og_tag("og:image", image.url), 14 | card_tag("twitter:image", image.url), 15 | ] 16 | end 17 | 18 | def image 19 | @image ||= seo_field_with_fallback(:image, item_image) 20 | end 21 | 22 | def item_image 23 | item && item.fields 24 | .select { |field| field.field_type == "file" } 25 | .map { |field| item[field.api_key] } 26 | .compact 27 | .find do |image| 28 | image.width && image.height && 29 | image.width >= 200 && image.height >= 200 30 | end 31 | end 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /spec/dato/utils/build_modular_block_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | module Dato 6 | module Utils 7 | describe BuildModularBlock do 8 | describe "#build" do 9 | it "returns an array of tags" do 10 | attributes = { 11 | id: "111", 12 | item_type: "1234", 13 | title: "Title", 14 | description: "Description", 15 | } 16 | payload = { 17 | id: "111", 18 | type: "item", 19 | attributes: { 20 | title: "Title", 21 | description: "Description", 22 | }, 23 | relationships: { 24 | item_type: { 25 | data: { 26 | id: "1234", 27 | type: "item_type", 28 | }, 29 | }, 30 | }, 31 | } 32 | expect(BuildModularBlock.build(attributes)).to eq payload 33 | end 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /spec/dato/utils/meta_tags/article_publisher_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | module Dato 6 | module Utils 7 | module MetaTags 8 | describe ArticlePublisher do 9 | include_context "items repo" 10 | subject(:builder) { described_class.new(item, site) } 11 | 12 | describe "#build" do 13 | let(:result) { builder.build } 14 | 15 | context "with FB page not set" do 16 | it "returns no tags" do 17 | expect(result).to be_nil 18 | end 19 | end 20 | 21 | context "with FB page set" do 22 | let(:global_seo) do 23 | { 24 | facebook_page_url: "http://facebook.com/mark.smith", 25 | } 26 | end 27 | 28 | it "returns robots meta tag" do 29 | expect(result[:attributes][:content]).to eq("http://facebook.com/mark.smith") 30 | end 31 | end 32 | end 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/dato/dump/dsl/root.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "dato/dump/dsl/directory" 4 | require "dato/dump/dsl/create_post" 5 | require "dato/dump/dsl/create_data_file" 6 | require "dato/dump/dsl/add_to_data_file" 7 | 8 | require "dato/dump/operation/directory" 9 | 10 | module Dato 11 | module Dump 12 | module Dsl 13 | class Root 14 | include Dsl::CreateDataFile 15 | include Dsl::CreatePost 16 | include Dsl::AddToDataFile 17 | 18 | attr_reader :dato, :operations 19 | 20 | def initialize(config_code, dato, operations) 21 | @dato = dato 22 | @operations = operations 23 | 24 | # rubocop:disable Security/Eval 25 | eval(config_code) 26 | # rubocop:enable Security/Eval 27 | end 28 | 29 | def directory(path, &block) 30 | operation = Operation::Directory.new(operations, path) 31 | operations.add operation 32 | 33 | Directory.new(dato, operation, &block) 34 | end 35 | end 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Cantiere Creativo SRL 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/dato/site/client.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "dato/api_client" 4 | require "dato/upload/file" 5 | require "dato/upload/create_upload_path" 6 | 7 | module Dato 8 | module Site 9 | class Client 10 | include ApiClient 11 | 12 | json_schema "site-api" 13 | 14 | def create_upload_path(path_or_url) 15 | file = Upload::CreateUploadPath.new(self, path_or_url) 16 | file.upload_path 17 | end 18 | 19 | def upload_file(path_or_url, upload_attributes = {}, field_attributes = {}) 20 | file = Upload::File.new(self, path_or_url, upload_attributes, field_attributes) 21 | file.upload 22 | end 23 | 24 | def upload_image(path_or_url, upload_attributes = {}, field_attributes = {}) 25 | file = Upload::File.new(self, path_or_url, upload_attributes, field_attributes) 26 | file.upload 27 | end 28 | 29 | def pusher_token(socket_id, channel) 30 | request( 31 | :post, 32 | "/pusher/authenticate", 33 | { socket_id: socket_id, channel_name: channel }, 34 | ) 35 | end 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Stefano Verna 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/dato/utils/meta_tags/title.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "dato/utils/meta_tags/base" 4 | 5 | module Dato 6 | module Utils 7 | module MetaTags 8 | class Title < Base 9 | def build 10 | return unless item_title 11 | 12 | [ 13 | content_tag("title", item_title_with_suffix), 14 | og_tag("og:title", item_title), 15 | card_tag("twitter:title", item_title), 16 | ] 17 | end 18 | 19 | def title_field 20 | item && item.item_type.title_field 21 | end 22 | 23 | def item_title 24 | @item_title ||= seo_field_with_fallback( 25 | :title, 26 | title_field && item[title_field.api_key], 27 | ) 28 | end 29 | 30 | def suffix 31 | (site.global_seo && site.global_seo.title_suffix) || "" 32 | end 33 | 34 | def item_title_with_suffix 35 | if (item_title + suffix).size <= 60 36 | item_title + suffix 37 | else 38 | item_title 39 | end 40 | end 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/dato/dump/runner.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "dato/dump/dsl/root" 4 | require "dato/dump/operation/root" 5 | require "dato/dump/ssg_detector" 6 | require "dato/local/loader" 7 | 8 | module Dato 9 | module Dump 10 | class Runner 11 | attr_reader :config_path, :client, :destination_path, :preview_mode, :loader 12 | 13 | def initialize(config_path, client, preview_mode, loader, destination_path = Dir.pwd) 14 | @config_path = config_path 15 | @preview_mode = preview_mode 16 | @client = client 17 | @destination_path = destination_path 18 | @loader = loader 19 | end 20 | 21 | def run 22 | I18n.available_locales = loader.items_repo.available_locales 23 | I18n.locale = I18n.available_locales.first 24 | 25 | Dsl::Root.new( 26 | File.read(config_path), 27 | loader.items_repo, 28 | operation, 29 | ) 30 | 31 | operation.perform 32 | 33 | puts "\e[32m✓\e[0m Done!" 34 | end 35 | 36 | def operation 37 | @operation ||= Operation::Root.new(destination_path) 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /spec/dato/json_api_serializer_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | module Dato 6 | describe JsonApiSerializer do 7 | subject(:serializer) do 8 | described_class.new(type: "menu_item", link: link) 9 | end 10 | 11 | let(:link) do 12 | response = VCR.use_cassette("json_schema", record: :new_episodes) do 13 | url = URI.parse("https://site-api.datocms.com/docs/site-api-hyperschema.json") 14 | Net::HTTP.get(url) 15 | end 16 | 17 | schema = JsonSchema.parse!(JSON.parse(response)) 18 | schema.expand_references! 19 | 20 | schema.definitions["menu_item"].links.find do |x| 21 | x.rel == "create" 22 | end 23 | end 24 | 25 | describe "#serialize" do 26 | it "returns a JSON-API serialized version of the argument" do 27 | expect(serializer.serialize({ label: "Ciao", position: 1 }, "12")).to eq( 28 | data: { 29 | id: "12", 30 | type: "menu_item", 31 | attributes: { 32 | label: "Ciao", 33 | position: 1, 34 | }, 35 | }, 36 | ) 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/dato/json_schema_type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dato 4 | class JsonSchemaType 5 | attr_reader :schema 6 | 7 | def initialize(schema) 8 | @schema = schema 9 | end 10 | 11 | def call 12 | type_property = find_info_for_type_property 13 | 14 | return nil unless type_property 15 | 16 | type_property.pattern.to_s.gsub(/(^(\(\?-mix:\^)|(\$\))$)/, "") 17 | end 18 | 19 | private 20 | 21 | def find_info_for_type_property 22 | entity = find_entity_in_data 23 | 24 | return nil unless entity 25 | 26 | entity.properties["type"] 27 | end 28 | 29 | def find_entity_in_data 30 | return nil if !schema || !schema.properties["data"] 31 | 32 | if schema.properties["data"].type.first == "array" 33 | return schema.properties["data"].items if schema.properties["data"].items 34 | 35 | return nil 36 | end 37 | 38 | return schema.properties["data"] if schema.properties["data"].type.first == "object" 39 | 40 | if schema.properties["data"].any_of 41 | return schema.properties["data"].any_of.reject { |x| x.definitions.type.example == "job" } 42 | end 43 | 44 | nil 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/dato/dump/operation/add_to_data_file.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "dato/dump/format" 4 | 5 | module Dato 6 | module Dump 7 | module Operation 8 | class AddToDataFile 9 | attr_reader :context, :path, :format, :value 10 | 11 | def initialize(context, path, format, value) 12 | @context = context 13 | @path = path 14 | @format = format 15 | @value = value 16 | end 17 | 18 | def perform 19 | complete_path = File.join(context.path, path) 20 | FileUtils.mkdir_p(File.dirname(complete_path)) 21 | 22 | content_to_add = Format.dump(format, value) 23 | 24 | old_content = if File.exist? complete_path 25 | ::File.read(complete_path) 26 | else 27 | "" 28 | end 29 | 30 | new_content = old_content.sub( 31 | /\n*(#\s*datocms:start.*#\s*datocms:end|\Z)/m, 32 | "\n\n# datocms:start\n#{content_to_add}\n# datocms:end", 33 | ) 34 | 35 | File.open(complete_path, "w") do |f| 36 | f.write new_content 37 | end 38 | end 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /spec/dato/account/client_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | module Dato 6 | module Account 7 | describe Client, :vcr do 8 | let(:client) do 9 | generate_account_client! 10 | end 11 | 12 | describe "Not found" do 13 | it "raises Dato::ApiError" do 14 | expect { client.sites.find(9999) }.to raise_error Dato::ApiError 15 | end 16 | end 17 | 18 | describe "Account" do 19 | it "fetch, update" do 20 | account = client.account.find 21 | 22 | client.account.update(account.merge(company: "Dundler Mifflin")) 23 | expect(client.account.find[:company]).to eq "Dundler Mifflin" 24 | end 25 | end 26 | 27 | describe "Sites" do 28 | it "fetch, create, update and destroy" do 29 | name = "Integration test" 30 | 31 | new_site = client.sites.create(name: name) 32 | 33 | client.sites.update( 34 | new_site[:id], 35 | new_site.merge(name: "#{name}!"), 36 | ) 37 | 38 | expect(client.sites.find(new_site[:id])[:name]).to eq("#{name}!") 39 | 40 | client.sites.destroy(new_site[:id]) 41 | end 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/dato/local/field_type/theme.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dato 4 | module Local 5 | module FieldType 6 | class Theme 7 | attr_reader :primary_color, :dark_color, :light_color, :accent_color 8 | 9 | def self.parse(value, repo) 10 | value && new( 11 | value[:logo], 12 | value[:primary_color], 13 | value[:dark_color], 14 | value[:light_color], 15 | value[:accent_color], 16 | repo, 17 | ) 18 | end 19 | 20 | def initialize(logo, primary_color, dark_color, light_color, accent_color, repo) 21 | @logo = logo 22 | @primary_color = primary_color 23 | @dark_color = dark_color 24 | @light_color = light_color 25 | @accent_color = accent_color 26 | @repo = repo 27 | end 28 | 29 | def logo 30 | @logo && UploadId.parse(@logo, @repo) 31 | end 32 | 33 | def to_hash(*args) 34 | { 35 | primary_color: primary_color, 36 | dark_color: dark_color, 37 | light_color: light_color, 38 | accent_color: accent_color, 39 | logo: logo && logo.to_hash(*args), 40 | } 41 | end 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /spec/dato/local/entities_repo_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | module Dato 6 | module Local 7 | RSpec.describe EntitiesRepo do 8 | subject(:source) { described_class.new(payload) } 9 | 10 | describe "#entities" do 11 | context "a payload with a data key" do 12 | context "object" do 13 | let(:payload) do 14 | { data: { id: "bar", type: "item" } } 15 | end 16 | 17 | it "inserts the object into entities" do 18 | expect(source.entities["item"]["bar"]).to be_a JsonApiEntity 19 | end 20 | end 21 | 22 | context "array" do 23 | let(:payload) do 24 | { data: [{ id: "bar", type: "item" }] } 25 | end 26 | 27 | it "inserts the objects into entities" do 28 | expect(source.entities["item"]["bar"]).to be_a JsonApiEntity 29 | end 30 | end 31 | end 32 | 33 | context "a payload with an included key" do 34 | let(:payload) do 35 | { included: [{ id: "bar", type: "item" }] } 36 | end 37 | 38 | it "inserts the objects into entities" do 39 | expect(source.entities["item"]["bar"]).to be_a JsonApiEntity 40 | end 41 | end 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/dato/utils/seo_tags_builder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "dato/utils/meta_tags/title" 4 | require "dato/utils/meta_tags/description" 5 | require "dato/utils/meta_tags/image" 6 | require "dato/utils/meta_tags/robots" 7 | require "dato/utils/meta_tags/og_locale" 8 | require "dato/utils/meta_tags/og_type" 9 | require "dato/utils/meta_tags/og_site_name" 10 | require "dato/utils/meta_tags/article_modified_time" 11 | require "dato/utils/meta_tags/article_publisher" 12 | require "dato/utils/meta_tags/twitter_card" 13 | require "dato/utils/meta_tags/twitter_site" 14 | 15 | module Dato 16 | module Utils 17 | class SeoTagsBuilder 18 | META_TAGS = [ 19 | MetaTags::Title, 20 | MetaTags::Description, 21 | MetaTags::Image, 22 | MetaTags::Robots, 23 | MetaTags::OgLocale, 24 | MetaTags::OgType, 25 | MetaTags::OgSiteName, 26 | MetaTags::ArticleModifiedTime, 27 | MetaTags::ArticlePublisher, 28 | MetaTags::TwitterCard, 29 | MetaTags::TwitterSite, 30 | ].freeze 31 | 32 | attr_reader :site, :item 33 | 34 | def initialize(item, site) 35 | @item = item 36 | @site = site 37 | end 38 | 39 | def meta_tags 40 | META_TAGS.map do |klass| 41 | klass.new(item, site).build 42 | end.flatten.compact 43 | end 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /spec/dato/local/field_type/upload_id_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | module Dato 6 | module Local 7 | module FieldType 8 | RSpec.describe Dato::Local::FieldType::UploadId do 9 | subject(:upload) { described_class.parse(attributes, repo) } 10 | 11 | let(:repo) { instance_double("Dato::Local::ItemsRepo", site: site, entities_repo: entities_repo) } 12 | let(:site) { instance_double("Dato::Local::Site", entity: site_entity) } 13 | let(:site_entity) { double("Dato::Local::JsonApiEntity", imgix_host: "foobar.com") } 14 | let(:entities_repo) { instance_double("Dato::Local::EntitiesRepo", find_entity: upload_entity) } 15 | let(:upload_entity) { double("Dato::Local::JsonApiEntity", attributes) } 16 | 17 | let(:attributes) do 18 | { 19 | path: "/foo.png", 20 | format: "jpg", 21 | size: 4000, 22 | width: 20, 23 | height: 20, 24 | } 25 | end 26 | 27 | it "responds to path, format and size methods" do 28 | expect(upload.path).to eq "/foo.png" 29 | expect(upload.format).to eq "jpg" 30 | expect(upload.size).to eq 4000 31 | expect(upload.width).to eq 20 32 | expect(upload.height).to eq 20 33 | end 34 | 35 | it "responds to url method" do 36 | expect(upload.url(w: 300)).to eq "https://foobar.com/foo.png?w=300" 37 | end 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /spec/dato/local/field_type/video_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | module Dato 6 | module Local 7 | module FieldType 8 | RSpec.describe Video do 9 | subject(:video) { described_class.parse(attributes, nil) } 10 | let(:attributes) do 11 | { 12 | url: "https://www.youtube.com/watch?v=oHg5SJYRHA0", 13 | provider_uid: "oHg5SJYRHA0", 14 | thumbnail_url: "http://i3.ytimg.com/vi/oHg5SJYRHA0/hqdefault.jpg", 15 | title: "RickRoll'D", 16 | provider: "youtube", 17 | width: 560, 18 | height: 315, 19 | } 20 | end 21 | 22 | it "responds to path, format, size, width and height" do 23 | expect(video.url).to eq "https://www.youtube.com/watch?v=oHg5SJYRHA0" 24 | expect(video.provider).to eq "youtube" 25 | expect(video.provider_uid).to eq "oHg5SJYRHA0" 26 | expect(video.thumbnail_url).to eq "http://i3.ytimg.com/vi/oHg5SJYRHA0/hqdefault.jpg" 27 | expect(video.title).to eq "RickRoll'D" 28 | expect(video.width).to eq 560 29 | expect(video.height).to eq 315 30 | end 31 | 32 | describe "iframe_embed" do 33 | it "returns a iframe embed HTML fragment" do 34 | expect(video.iframe_embed).to eq '' 35 | end 36 | end 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /spec/dato/local/field_type/seo_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | module Dato 6 | module Local 7 | module FieldType 8 | RSpec.describe Seo do 9 | subject(:seo) { described_class.parse(attributes, repo) } 10 | 11 | let(:repo) { instance_double("Dato::Local::ItemsRepo", site: site, entities_repo: entities_repo) } 12 | let(:site) { instance_double("Dato::Local::Site", entity: site_entity) } 13 | let(:site_entity) { double("Dato::Local::JsonApiEntity", imgix_host: "foobar.com") } 14 | let(:entities_repo) { instance_double("Dato::Local::EntitiesRepo", find_entity: upload_entity) } 15 | let(:upload_entity) { double("Dato::Local::JsonApiEntity", upload_attributes) } 16 | 17 | let(:attributes) do 18 | { 19 | title: "title", 20 | description: "description", 21 | image: upload_entity, 22 | } 23 | end 24 | 25 | let(:upload_attributes) do 26 | { 27 | id: "333", 28 | path: "/foo.png", 29 | format: "jpg", 30 | size: 4000, 31 | width: 20, 32 | height: 20, 33 | } 34 | end 35 | 36 | it "responds to title, description and image methods" do 37 | expect(seo.title).to eq "title" 38 | expect(seo.description).to eq "description" 39 | expect(seo.image).to be_a Dato::Local::FieldType::UploadId 40 | expect(seo.image.path).to eq "/foo.png" 41 | end 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/dato/local/field_type/global_seo.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "dato/local/field_type/seo" 4 | 5 | module Dato 6 | module Local 7 | module FieldType 8 | class GlobalSeo 9 | attr_reader :site_name, :title_suffix, :twitter_account, :facebook_page_url 10 | 11 | def self.parse(value, repo) 12 | value && new( 13 | value[:site_name], 14 | value[:title_suffix], 15 | value[:twitter_account], 16 | value[:facebook_page_url], 17 | value[:fallback_seo], 18 | repo, 19 | ) 20 | end 21 | 22 | def initialize( 23 | site_name, 24 | title_suffix, 25 | twitter_account, 26 | facebook_page_url, 27 | fallback_seo, 28 | repo 29 | ) 30 | @site_name = site_name 31 | @title_suffix = title_suffix 32 | @twitter_account = twitter_account 33 | @facebook_page_url = facebook_page_url 34 | @fallback_seo = fallback_seo 35 | @repo = repo 36 | end 37 | 38 | def fallback_seo 39 | @fallback_seo && Seo.parse(@fallback_seo, @repo) 40 | end 41 | 42 | def to_hash(*args) 43 | { 44 | site_name: site_name, 45 | title_suffix: title_suffix, 46 | twitter_account: twitter_account, 47 | facebook_page_url: facebook_page_url, 48 | fallback_seo: fallback_seo && fallback_seo.to_hash(*args), 49 | } 50 | end 51 | end 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | 2 | AllCops: 3 | TargetRubyVersion: 2.5 4 | NewCops: enable 5 | 6 | Exclude: 7 | - "dato.gemspec" 8 | 9 | Style/SafeNavigation: 10 | Enabled: false 11 | 12 | Style/Documentation: 13 | Enabled: false 14 | 15 | Style/GuardClause: 16 | Enabled: false 17 | 18 | Naming/PredicateName: 19 | Enabled: false 20 | 21 | Style/MissingRespondToMissing: 22 | Enabled: false 23 | 24 | Style/EvalWithLocation: 25 | Enabled: false 26 | 27 | Metrics/BlockNesting: 28 | Enabled: false 29 | 30 | Metrics/MethodLength: 31 | Enabled: false 32 | 33 | Metrics/BlockLength: 34 | Enabled: false 35 | 36 | Metrics/ClassLength: 37 | Enabled: false 38 | 39 | Metrics/ModuleLength: 40 | Enabled: false 41 | 42 | Metrics/CyclomaticComplexity: 43 | Enabled: false 44 | 45 | Metrics/AbcSize: 46 | Enabled: false 47 | 48 | Metrics/PerceivedComplexity: 49 | Enabled: false 50 | 51 | Layout/LineLength: 52 | Exclude: 53 | - "spec/**/*" 54 | - "lib/dato/site/repo/*" 55 | - "lib/dato/account/repo/*" 56 | - "lib/dato/account/repo/*" 57 | - "dato.gemspec" 58 | 59 | Metrics/ParameterLists: 60 | Exclude: 61 | - "lib/dato/local/field_type/*" 62 | 63 | Style/TrailingCommaInArrayLiteral: 64 | EnforcedStyleForMultiline: comma 65 | 66 | Style/TrailingCommaInArguments: 67 | EnforcedStyleForMultiline: comma 68 | 69 | Style/TrailingCommaInHashLiteral: 70 | EnforcedStyleForMultiline: comma 71 | 72 | Style/AndOr: 73 | EnforcedStyle: conditionals 74 | 75 | Style/StringLiterals: 76 | EnforcedStyle: double_quotes 77 | 78 | Style/StringLiteralsInInterpolation: 79 | EnforcedStyle: double_quotes 80 | -------------------------------------------------------------------------------- /lib/dato/local/field_type/color.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dato 4 | module Local 5 | module FieldType 6 | class Color 7 | attr_reader :red, :green, :blue 8 | 9 | def self.parse(value, _repo) 10 | value && new( 11 | value[:red], 12 | value[:green], 13 | value[:blue], 14 | value[:alpha], 15 | ) 16 | end 17 | 18 | def initialize(red, green, blue, alpha) 19 | @red = red 20 | @green = green 21 | @blue = blue 22 | @alpha = alpha 23 | end 24 | 25 | def rgb 26 | if @alpha == 255 27 | "rgb(#{red}, #{green}, #{blue})" 28 | else 29 | "rgba(#{red}, #{green}, #{blue}, #{alpha})" 30 | end 31 | end 32 | 33 | def alpha 34 | @alpha / 255.0 35 | end 36 | 37 | def hex 38 | r = red.to_s(16) 39 | g = green.to_s(16) 40 | b = blue.to_s(16) 41 | a = (alpha * 255).to_i.to_s(16) 42 | 43 | r = "0#{r}" if r.length == 1 44 | g = "0#{g}" if g.length == 1 45 | b = "0#{b}" if b.length == 1 46 | a = "0#{a}" if a.length == 1 47 | 48 | hex = "##{r}#{g}#{b}" 49 | 50 | hex += a if a != "ff" 51 | 52 | hex 53 | end 54 | 55 | def to_hash(*_args) 56 | { 57 | red: red, 58 | green: green, 59 | blue: blue, 60 | rgb: rgb, 61 | hex: hex, 62 | } 63 | end 64 | end 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /lib/dato/json_schema_relationships.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dato 4 | class JsonSchemaRelationships 5 | attr_reader :schema 6 | 7 | def initialize(schema) 8 | @schema = schema 9 | end 10 | 11 | def relationships 12 | return {} if !schema || !schema.properties["data"] 13 | 14 | entity = if schema.properties["data"].type.first == "array" 15 | schema.properties["data"].items 16 | else 17 | schema.properties["data"] 18 | end 19 | 20 | return {} if !entity || !entity.properties["relationships"] || !entity.properties["relationships"] 21 | 22 | relationships = entity.properties["relationships"].properties 23 | 24 | relationships.each_with_object({}) do |(relationship, schema), acc| 25 | is_collection = schema.properties["data"].type.first == "array" 26 | 27 | types = if is_collection 28 | [type(schema.properties["data"].items)] 29 | elsif schema.properties["data"].type.first == "object" 30 | [type(schema.properties["data"])] 31 | else 32 | schema.properties["data"].any_of.map do |option| 33 | type(option) 34 | end.compact 35 | end 36 | 37 | acc[relationship.to_sym] = { 38 | collection: is_collection, 39 | types: types, 40 | } 41 | end 42 | end 43 | 44 | def type(definition) 45 | definition.properties["type"].pattern.source.gsub(/(^\^|\$$)/, "") if definition.properties["type"] 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/dato/utils/meta_tags/base.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "forwardable" 4 | require "dato/local/field_type/seo" 5 | 6 | module Dato 7 | module Utils 8 | module MetaTags 9 | class Base 10 | attr_reader :site, :item 11 | 12 | def initialize(item, site) 13 | @item = item 14 | @site = site 15 | end 16 | 17 | def seo_field_with_fallback(attribute, alternative) 18 | fallback_seo = site.global_seo && site.global_seo.fallback_seo 19 | 20 | seo_field = item && 21 | item.fields.detect { |f| f.field_type == "seo" } 22 | 23 | item_seo_value = seo_field && 24 | item[seo_field.api_key] && 25 | item[seo_field.api_key].send(attribute) 26 | 27 | fallback_seo_value = fallback_seo && 28 | fallback_seo.send(attribute) 29 | 30 | item_seo_value.presence || alternative.presence || fallback_seo_value 31 | end 32 | 33 | def tag(tag_name, attributes) 34 | { tag_name: tag_name, attributes: attributes } 35 | end 36 | 37 | def meta_tag(name, content) 38 | tag("meta", name: name, content: content) 39 | end 40 | 41 | def og_tag(property, content) 42 | tag("meta", property: property, content: content) 43 | end 44 | 45 | def card_tag(name, content) 46 | meta_tag(name, content) 47 | end 48 | 49 | def content_tag(tag_name, content) 50 | { tag_name: tag_name, content: content } 51 | end 52 | end 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/dato/json_api_deserializer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "dato/json_schema_relationships" 4 | 5 | module Dato 6 | class JsonApiDeserializer 7 | attr_reader :schema 8 | 9 | def initialize(schema) 10 | @schema = schema 11 | end 12 | 13 | def deserialize(data) 14 | return nil unless data 15 | 16 | data = data[:data] 17 | 18 | if data.is_a? Array 19 | data.map { |resource| deserialize_resource(resource) } 20 | else 21 | deserialize_resource(data) 22 | end 23 | end 24 | 25 | def deserialize_resource(data) 26 | result = { id: data[:id] } 27 | result[:meta] = data[:meta] if data[:meta] 28 | result.merge!(data[:attributes]) if data[:attributes] 29 | 30 | if data[:relationships] 31 | relationships.each do |relationship, meta| 32 | next unless data[:relationships][relationship] 33 | 34 | rel_data = data[:relationships][relationship][:data] 35 | 36 | result[relationship] = if meta[:types].length > 1 37 | rel_data 38 | elsif !rel_data 39 | nil 40 | elsif meta[:collection] 41 | rel_data.map { |ref| ref[:id] } 42 | else 43 | rel_data[:id] 44 | end 45 | end 46 | end 47 | 48 | result.with_indifferent_access 49 | end 50 | 51 | def relationships 52 | @relationships ||= JsonSchemaRelationships.new(schema).relationships 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/dato/local/field_type/upload_id.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "imgix" 4 | 5 | module Dato 6 | module Local 7 | module FieldType 8 | class UploadId 9 | attr_reader :path, :format, :size, :width, :height 10 | 11 | def self.parse(upload_id, repo) 12 | if upload_id 13 | upload = repo.entities_repo.find_entity("upload", upload_id) 14 | 15 | if upload 16 | new( 17 | upload.path, 18 | upload.format, 19 | upload.size, 20 | upload.width, 21 | upload.height, 22 | repo.site.entity.imgix_host, 23 | ) 24 | end 25 | end 26 | end 27 | 28 | def initialize( 29 | path, 30 | format, 31 | size, 32 | width, 33 | height, 34 | imgix_host 35 | ) 36 | @path = path 37 | @format = format 38 | @size = size 39 | @imgix_host = imgix_host 40 | @width = width 41 | @height = height 42 | end 43 | 44 | def file 45 | Imgix::Client.new( 46 | domain: @imgix_host, 47 | secure: true, 48 | include_library_param: false, 49 | ).path(path) 50 | end 51 | 52 | def url(opts = {}) 53 | file.to_url(opts) 54 | end 55 | 56 | def to_hash(*_args) 57 | { 58 | format: format, 59 | size: size, 60 | width: width, 61 | height: height, 62 | url: url, 63 | } 64 | end 65 | end 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /lib/dato/local/entities_repo.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "dato/local/json_api_entity" 4 | 5 | module Dato 6 | module Local 7 | class EntitiesRepo 8 | attr_reader :entities 9 | 10 | def initialize(*payloads) 11 | @entities = {} 12 | upsert_entities(*payloads) 13 | end 14 | 15 | def find_entities_of_type(type) 16 | entities.fetch(type, {}).values 17 | end 18 | 19 | def find_entity(type, id) 20 | entities.fetch(type, {}).fetch(id, nil) 21 | end 22 | 23 | def destroy_entities(type, ids) 24 | ids.each do |id| 25 | entities.fetch(type, {}).delete(id) 26 | end 27 | end 28 | 29 | def destroy_item_type(id) 30 | entities.fetch("item", {}).delete_if { |_item_id, item| item.item_type.id == id } 31 | entities.fetch("item_type", {}).delete(id) 32 | end 33 | 34 | def upsert_entities(*payloads) 35 | payloads.each do |payload| 36 | EntitiesRepo.payload_entities(payload).each do |entity_payload| 37 | object = JsonApiEntity.new(entity_payload, self) 38 | @entities[object.type] ||= {} 39 | @entities[object.type][object.id] = object 40 | end 41 | end 42 | end 43 | 44 | def self.payload_entities(payload) 45 | acc = [] 46 | 47 | if payload[:data] 48 | acc = if payload[:data].is_a? Array 49 | acc + payload[:data] 50 | else 51 | acc + [payload[:data]] 52 | end 53 | end 54 | 55 | acc += payload[:included] if payload[:included] 56 | 57 | acc 58 | end 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/dato/local/field_type/structured_text.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dato 4 | module Local 5 | module FieldType 6 | class StructuredText 7 | def self.parse(value, repo) 8 | new(value, repo) 9 | end 10 | 11 | def initialize(value, repo) 12 | @value = value 13 | @repo = repo 14 | end 15 | 16 | attr_reader :value 17 | 18 | def blocks 19 | find_all_nodes("block").map do |node| 20 | @repo.find(node["item"]) 21 | end.uniq 22 | end 23 | 24 | def links 25 | find_all_nodes(%w[inlineItem itemLink]).map do |node| 26 | @repo.find(node["item"]) 27 | end.uniq 28 | end 29 | 30 | def find_all_nodes(types) 31 | return [] if value.nil? 32 | 33 | types = Array(types) 34 | result = [] 35 | 36 | visit(value["document"]) do |node| 37 | result << node if node.is_a?(Hash) && types.include?(node["type"]) 38 | end 39 | 40 | result 41 | end 42 | 43 | def visit(node, &block) 44 | if node.is_a?(Hash) && node["children"].is_a?(Array) 45 | node["children"].each do |child| 46 | visit(child, &block) 47 | end 48 | end 49 | 50 | block.call(node) 51 | end 52 | 53 | def to_hash(max_depth = 3, current_depth = 0) 54 | { 55 | value: value, 56 | links: links.map { |item| item.to_hash(max_depth, current_depth) }, 57 | blocks: blocks.map { |item| item.to_hash(max_depth, current_depth) }, 58 | } 59 | end 60 | end 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /spec/dato/dump/ssg_detector_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | module Dato 6 | module Dump 7 | RSpec.describe SsgDetector do 8 | subject(:detector) { described_class.new(path) } 9 | 10 | describe ".detect" do 11 | context "package.json-based SSGs" do 12 | let(:path) { "./spec/fixtures/ssg/hexo" } 13 | 14 | it "detects it" do 15 | expect(detector.detect).to eq "hexo" 16 | end 17 | end 18 | 19 | context "Gemfile-based SSGs" do 20 | let(:path) { "./spec/fixtures/ssg/middleman" } 21 | 22 | it "detects it" do 23 | expect(detector.detect).to eq "middleman" 24 | end 25 | end 26 | 27 | context "Hugo SSGs" do 28 | context "with config.toml" do 29 | let(:path) { "./spec/fixtures/ssg/hugo/toml" } 30 | 31 | it "detects it" do 32 | expect(detector.detect).to eq "hugo" 33 | end 34 | end 35 | 36 | context "with config.yaml" do 37 | let(:path) { "./spec/fixtures/ssg/hugo/yaml" } 38 | 39 | it "detects it" do 40 | expect(detector.detect).to eq "hugo" 41 | end 42 | end 43 | 44 | context "with config.json" do 45 | let(:path) { "./spec/fixtures/ssg/hugo/json" } 46 | 47 | it "detects it" do 48 | expect(detector.detect).to eq "hugo" 49 | end 50 | end 51 | end 52 | 53 | context "otherwise" do 54 | let(:path) { "." } 55 | 56 | it 'fall backs to "unknown"' do 57 | expect(detector.detect).to eq "unknown" 58 | end 59 | end 60 | end 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "simplecov" 4 | require "coveralls" 5 | 6 | ENV["SITE_API_BASE_URL"] ||= "https://site-api.datocms.com" 7 | ENV["ACCOUNT_API_BASE_URL"] ||= "https://account-api.datocms.com" 8 | 9 | SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter.new([ 10 | SimpleCov::Formatter::HTMLFormatter, 11 | Coveralls::SimpleCov::Formatter, 12 | ]) 13 | 14 | SimpleCov.start 15 | 16 | $LOAD_PATH.unshift File.expand_path("../lib", __dir__) 17 | 18 | Dir["spec/support/**/*.rb"].each { |f| require_relative "../#{f}" } 19 | 20 | require "pry" 21 | require "vcr" 22 | require "i18n" 23 | require "i18n/backend/fallbacks" 24 | require "webmock/rspec" 25 | 26 | I18n.enforce_available_locales = false 27 | I18n.available_locales = %i[it en ru] 28 | I18n::Backend::Simple.include I18n::Backend::Fallbacks 29 | I18n.fallbacks[:ru] = [:"es-ES"] 30 | 31 | VCR.configure do |config| 32 | config.cassette_library_dir = "spec/fixtures/vcr_cassettes" 33 | config.hook_into :webmock 34 | config.preserve_exact_body_bytes do |http_message| 35 | http_message.body.encoding.name == "ASCII-8BIT" || 36 | !http_message.body.valid_encoding? 37 | end 38 | config.configure_rspec_metadata! 39 | 40 | config.register_request_matcher :modified_body do |request1, request2| 41 | if URI(request1.uri).path == "/account" && 42 | URI(request2.uri).path == "/account" && 43 | request1.method == request2.method 44 | 45 | true 46 | else 47 | request1.body == request2.body 48 | end 49 | end 50 | 51 | config.default_cassette_options = { 52 | match_requests_on: %i[method uri query modified_body], 53 | } 54 | end 55 | 56 | require "dato" 57 | -------------------------------------------------------------------------------- /lib/dato/local/site.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "forwardable" 4 | require "dato/utils/locale_value" 5 | 6 | module Dato 7 | module Local 8 | class Site 9 | extend Forwardable 10 | 11 | attr_reader :entity 12 | 13 | def_delegators :entity, :id, :name, :locales, :domain, 14 | :internal_domain, :no_index, :frontend_url 15 | 16 | def initialize(entity, items_repo) 17 | @entity = entity 18 | @items_repo = items_repo 19 | end 20 | 21 | def global_seo 22 | read_attribute(:global_seo, FieldType::GlobalSeo, locales.size > 1) 23 | end 24 | 25 | def theme 26 | read_attribute(:theme, FieldType::Theme, false) 27 | end 28 | 29 | def favicon 30 | read_attribute(:favicon, FieldType::UploadId, false) 31 | end 32 | 33 | def to_s 34 | "#" 35 | end 36 | alias inspect to_s 37 | 38 | def favicon_meta_tags(theme_color = nil) 39 | Utils::FaviconTagsBuilder.new(self, theme_color).meta_tags 40 | end 41 | 42 | def to_hash 43 | attributes = %i[ 44 | id name locales theme domain internal_domain 45 | no_index global_seo favicon frontend_url 46 | ] 47 | 48 | attributes.each_with_object({}) do |attribute, result| 49 | value = send(attribute) 50 | result[attribute] = if value.respond_to?(:to_hash) 51 | value.to_hash 52 | else 53 | value 54 | end 55 | end 56 | end 57 | 58 | private 59 | 60 | def read_attribute(method, type_klass, localized) 61 | value = if localized 62 | obj = entity[method] || {} 63 | 64 | Utils::LocaleValue.find(obj) 65 | else 66 | entity[method] 67 | end 68 | 69 | type_klass.parse(value, @items_repo) 70 | end 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /dato.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | # frozen_string_literal: true 3 | lib = File.expand_path('../lib', __FILE__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require 'dato/version' 6 | 7 | Gem::Specification.new do |spec| 8 | spec.name = 'dato' 9 | spec.version = Dato::VERSION 10 | spec.authors = ['Stefano Verna'] 11 | spec.email = ['s.verna@cantierecreativo.net'] 12 | 13 | spec.summary = 'Ruby client for DatoCMS API' 14 | spec.description = 'Ruby client for DatoCMS API' 15 | spec.homepage = 'https://github.com/datocms/ruby-datocms-client' 16 | spec.license = 'MIT' 17 | 18 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|build|spec|features)/}) } 19 | spec.bindir = 'exe' 20 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 21 | spec.require_paths = ['lib'] 22 | 23 | spec.add_development_dependency 'bundler' 24 | spec.add_development_dependency 'rake' 25 | spec.add_development_dependency 'rspec' 26 | spec.add_development_dependency 'rubyzip' 27 | spec.add_development_dependency 'simplecov', '~> 0.17.0' 28 | spec.add_development_dependency 'vcr' 29 | spec.add_development_dependency 'webmock' 30 | spec.add_development_dependency 'rubocop' 31 | spec.add_development_dependency 'coveralls' 32 | spec.add_development_dependency 'pry' 33 | spec.add_development_dependency 'pry-byebug' 34 | spec.add_development_dependency 'front_matter_parser' 35 | 36 | spec.add_runtime_dependency 'faraday', ['>= 0.9.0'] 37 | spec.add_runtime_dependency 'faraday_middleware', ['>= 0.9.0'] 38 | spec.add_runtime_dependency 'activesupport', ['>= 4.2.7'] 39 | spec.add_runtime_dependency 'addressable' 40 | spec.add_runtime_dependency 'thor' 41 | spec.add_runtime_dependency 'imgix', ['~> 4'] 42 | spec.add_runtime_dependency 'toml' 43 | spec.add_runtime_dependency 'cacert' 44 | spec.add_runtime_dependency 'dotenv' 45 | spec.add_runtime_dependency 'pusher-client' 46 | spec.add_runtime_dependency 'listen' 47 | spec.add_runtime_dependency 'dato_json_schema' 48 | spec.add_runtime_dependency 'mime-types' 49 | end 50 | -------------------------------------------------------------------------------- /lib/dato/local/json_api_entity.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "dato/local/json_api_meta" 4 | 5 | module Dato 6 | module Local 7 | class JsonApiEntity 8 | attr_reader :payload, :data_source 9 | 10 | def initialize(payload, data_source) 11 | @payload = payload 12 | @data_source = data_source 13 | end 14 | 15 | def id 16 | @payload[:id] 17 | end 18 | 19 | def type 20 | @payload[:type] 21 | end 22 | 23 | def meta 24 | @meta ||= JsonApiMeta.new(@payload[:meta]) 25 | end 26 | 27 | def ==(other) 28 | if other.is_a? JsonApiEntity 29 | id == other.id && type == other.type 30 | else 31 | false 32 | end 33 | end 34 | 35 | def to_s 36 | "#" 37 | end 38 | alias inspect to_s 39 | 40 | def [](key) 41 | attributes[key.to_sym] 42 | end 43 | 44 | def respond_to_missing?(method, include_private = false) 45 | if attributes.key?(method) || relationships.key?(method) 46 | true 47 | else 48 | super 49 | end 50 | end 51 | 52 | private 53 | 54 | def attributes 55 | @payload.fetch(:attributes, {}) 56 | end 57 | 58 | def relationships 59 | @payload.fetch(:relationships, {}) 60 | end 61 | 62 | def dereference_linkage(linkage) 63 | case linkage 64 | when Array 65 | linkage.map do |item| 66 | data_source.find_entity(item[:type], item[:id]) 67 | end 68 | when Hash 69 | data_source.find_entity(linkage[:type], linkage[:id]) 70 | end 71 | end 72 | 73 | def method_missing(method, *arguments, &block) 74 | return super unless arguments.empty? 75 | 76 | if attributes.key?(method) 77 | attributes[method] 78 | elsif relationships.key?(method) 79 | dereference_linkage(relationships[method][:data]) 80 | else 81 | super 82 | end 83 | end 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /lib/dato/upload/create_upload_path.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "mime/types" 4 | require "tempfile" 5 | require "addressable" 6 | require "net/http" 7 | 8 | module Dato 9 | module Upload 10 | class CreateUploadPath 11 | attr_reader :client, :source 12 | 13 | def initialize(client, source) 14 | @client = client 15 | @source = source 16 | end 17 | 18 | def file 19 | @file ||= if http_source? 20 | uri = Addressable::URI.parse(source) 21 | ext = ::File.extname(uri.path).downcase 22 | tempfile = Tempfile.new(["file", ext]) 23 | tempfile.binmode 24 | tempfile.write(download_file(source)) 25 | tempfile.rewind 26 | tempfile 27 | else 28 | ::File.new(::File.expand_path(source)) 29 | end 30 | end 31 | 32 | def http_source? 33 | uri = Addressable::URI.parse(source) 34 | uri.scheme == "http" || uri.scheme == "https" 35 | rescue Addressable::URI::InvalidURIError 36 | false 37 | end 38 | 39 | def filename 40 | if http_source? 41 | ::File.basename(source) 42 | else 43 | ::File.basename(file.path) 44 | end 45 | end 46 | 47 | def upload_path 48 | upload_request = client.upload_request.create(filename: filename) 49 | uri = URI.parse(upload_request[:url]) 50 | 51 | mime_type = MIME::Types.of(filename).first 52 | 53 | request = Net::HTTP::Put.new(uri) 54 | request.add_field("Content-Type", mime_type.to_s) if mime_type 55 | request.body = file.read 56 | 57 | http = Net::HTTP.new(uri.host, uri.port) 58 | http.use_ssl = true 59 | 60 | http.request(request) 61 | 62 | upload_request[:id] 63 | end 64 | 65 | def download_file(url) 66 | connection = Faraday.new do |c| 67 | c.response :raise_error 68 | c.use FaradayMiddleware::FollowRedirects 69 | c.adapter :net_http 70 | end 71 | connection.get(url).body 72 | rescue Faraday::Error => e 73 | puts "Error during upload of #{url}: #{e.message}" 74 | raise e 75 | end 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /lib/dato/utils/favicon_tags_builder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dato 4 | module Utils 5 | class FaviconTagsBuilder 6 | attr_reader :theme_color, :site 7 | 8 | APPLE_TOUCH_ICON_SIZES = [57, 60, 72, 76, 114, 120, 144, 152, 180].freeze 9 | ICON_SIZES = [16, 32, 96, 192].freeze 10 | WINDOWS_SIZES = [[70, 70], [150, 150], [310, 310], [310, 150]].freeze 11 | 12 | def initialize(site, theme_color) 13 | @site = site 14 | @theme_color = theme_color 15 | end 16 | 17 | def meta_tags 18 | [ 19 | build_icon_tags, 20 | build_apple_icon_tags, 21 | build_windows_tags, 22 | build_color_tags, 23 | build_app_name_tag, 24 | ].flatten.compact 25 | end 26 | 27 | def build_apple_icon_tags 28 | return unless site.favicon 29 | 30 | APPLE_TOUCH_ICON_SIZES.map do |size| 31 | link_tag( 32 | "apple-touch-icon", 33 | url(size), 34 | sizes: "#{size}x#{size}", 35 | ) 36 | end 37 | end 38 | 39 | def build_icon_tags 40 | return unless site.favicon 41 | 42 | ICON_SIZES.map do |size| 43 | link_tag( 44 | "icon", 45 | url(size), 46 | sizes: "#{size}x#{size}", 47 | type: "image/#{site.favicon.format}", 48 | ) 49 | end 50 | end 51 | 52 | def build_windows_tags 53 | return unless site.favicon 54 | 55 | WINDOWS_SIZES.map do |(w, h)| 56 | meta_tag("msapplication-square#{w}x#{h}logo", url(w, h)) 57 | end 58 | end 59 | 60 | def build_app_name_tag 61 | meta_tag("application-name", site.name) 62 | end 63 | 64 | def build_color_tags 65 | return unless theme_color 66 | 67 | [ 68 | meta_tag("theme-color", theme_color), 69 | meta_tag("msapplication-TileColor", theme_color), 70 | ] 71 | end 72 | 73 | def url(width, height = width) 74 | site.favicon.url(w: width, h: height) 75 | end 76 | 77 | def meta_tag(name, value) 78 | { tag_name: "meta", attributes: { name: name, content: value } } 79 | end 80 | 81 | def link_tag(rel, href, attrs = {}) 82 | { tag_name: "link", attributes: attrs.merge(rel: rel, href: href) } 83 | end 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | As contributors and maintainers of this project, and in the interest of 4 | fostering an open and welcoming community, we pledge to respect all people who 5 | contribute through reporting issues, posting feature requests, updating 6 | documentation, submitting pull requests or patches, and other activities. 7 | 8 | We are committed to making participation in this project a harassment-free 9 | experience for everyone, regardless of level of experience, gender, gender 10 | identity and expression, sexual orientation, disability, personal appearance, 11 | body size, race, ethnicity, age, religion, or nationality. 12 | 13 | Examples of unacceptable behavior by participants include: 14 | 15 | * The use of sexualized language or imagery 16 | * Personal attacks 17 | * Trolling or insulting/derogatory comments 18 | * Public or private harassment 19 | * Publishing other's private information, such as physical or electronic 20 | addresses, without explicit permission 21 | * Other unethical or unprofessional conduct 22 | 23 | Project maintainers have the right and responsibility to remove, edit, or 24 | reject comments, commits, code, wiki edits, issues, and other contributions 25 | that are not aligned to this Code of Conduct, or to ban temporarily or 26 | permanently any contributor for other behaviors that they deem inappropriate, 27 | threatening, offensive, or harmful. 28 | 29 | By adopting this Code of Conduct, project maintainers commit themselves to 30 | fairly and consistently applying these principles to every aspect of managing 31 | this project. Project maintainers who do not follow or enforce the Code of 32 | Conduct may be permanently removed from the project team. 33 | 34 | This code of conduct applies both within project spaces and in public spaces 35 | when an individual is representing the project or its community. 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 38 | reported by contacting a project maintainer at s.verna@cantierecreativo.net. All 39 | complaints will be reviewed and investigated and will result in a response that 40 | is deemed necessary and appropriate to the circumstances. Maintainers are 41 | obligated to maintain confidentiality with regard to the reporter of an 42 | incident. 43 | 44 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 45 | version 1.3.0, available at 46 | [http://contributor-covenant.org/version/1/3/0/][version] 47 | 48 | [homepage]: http://contributor-covenant.org 49 | [version]: http://contributor-covenant.org/version/1/3/0/ -------------------------------------------------------------------------------- /spec/dato/utils/meta_tags/twitter_card_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | module Dato 6 | module Utils 7 | module MetaTags 8 | describe TwitterCard do 9 | include_context "items repo" 10 | 11 | subject(:builder) { described_class.new(item, site) } 12 | 13 | describe "#build" do 14 | let(:result) { builder.build } 15 | let(:card_value) { result[:attributes][:content] } 16 | 17 | context "with no fallback seo" do 18 | context "with no item" do 19 | it "returns no tags" do 20 | expect(card_value).to eq("summary") 21 | end 22 | end 23 | 24 | context "with item" do 25 | let(:item) { items_repo.articles.first } 26 | 27 | context "with no title" do 28 | context "no SEO" do 29 | it "returns no tags" do 30 | expect(card_value).to eq("summary") 31 | end 32 | end 33 | 34 | context "with SEO" do 35 | let(:seo) do 36 | { twitter_card: "foobar" } 37 | end 38 | 39 | it "returns seo title" do 40 | expect(card_value).to eq("foobar") 41 | end 42 | end 43 | end 44 | end 45 | end 46 | 47 | context "with fallback seo" do 48 | let(:global_seo) do 49 | { 50 | fallback_seo: { 51 | twitter_card: "default_summary", 52 | }, 53 | } 54 | end 55 | 56 | context "with no item" do 57 | it "returns fallback title" do 58 | expect(card_value).to eq("default_summary") 59 | end 60 | end 61 | 62 | context "with item" do 63 | let(:item) { items_repo.articles.first } 64 | 65 | context "no SEO" do 66 | it "returns fallback title" do 67 | expect(card_value).to eq("default_summary") 68 | end 69 | end 70 | 71 | context "with SEO" do 72 | let(:seo) do 73 | { twitter_card: "item_summary" } 74 | end 75 | 76 | it "returns seo title" do 77 | expect(card_value).to eq("item_summary") 78 | end 79 | end 80 | end 81 | end 82 | end 83 | end 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /lib/dato/local/field_type/video.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dato 4 | module Local 5 | module FieldType 6 | class Video 7 | attr_reader :url, :thumbnail_url, :title, :width, :height, :provider, :provider_url, :provider_uid 8 | 9 | def self.parse(value, _repo) 10 | value && new( 11 | value[:url], 12 | value[:thumbnail_url], 13 | value[:title], 14 | value[:width], 15 | value[:height], 16 | value[:provider], 17 | value[:provider_url], 18 | value[:provider_uid], 19 | ) 20 | end 21 | 22 | def initialize( 23 | url, 24 | thumbnail_url, 25 | title, 26 | width, 27 | height, 28 | provider, 29 | provider_url, 30 | provider_uid 31 | ) 32 | @url = url 33 | @thumbnail_url = thumbnail_url 34 | @title = title 35 | @width = width 36 | @height = height 37 | @provider = provider 38 | @provider_url = provider_url 39 | @provider_uid = provider_uid 40 | end 41 | 42 | def iframe_embed(width = self.width, height = self.height) 43 | # rubocop:disable Layout/LineLength 44 | case provider 45 | when "youtube" 46 | %() 47 | when "vimeo" 48 | %() 49 | when "facebook" 50 | %() 51 | end 52 | # rubocop:enable Layout/LineLength 53 | end 54 | 55 | def to_hash(*_args) 56 | { 57 | url: url, 58 | thumbnail_url: thumbnail_url, 59 | title: title, 60 | width: width, 61 | height: height, 62 | provider: provider, 63 | provider_url: provider_url, 64 | provider_uid: provider_uid, 65 | } 66 | end 67 | end 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/dato/cli.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "thor" 4 | require "dato/dump/runner" 5 | require "dato/dump/ssg_detector" 6 | require "listen" 7 | module Dato 8 | class Cli < Thor 9 | package_name "DatoCMS" 10 | 11 | desc "dump", "dumps DatoCMS content into local files" 12 | option :config, default: "dato.config.rb" 13 | option :token, default: ENV["DATO_API_TOKEN"], required: true 14 | option :environment, type: :string, required: false 15 | option :preview, default: false, type: :boolean 16 | option :watch, default: false, type: :boolean 17 | 18 | def dump 19 | config_file = File.expand_path(options[:config]) 20 | watch_mode = options[:watch] 21 | preview_mode = options[:preview] 22 | 23 | client = Dato::Site::Client.new( 24 | options[:token], 25 | environment: options[:environment], 26 | extra_headers: { 27 | "X-Reason" => "dump", 28 | "X-SSG" => Dump::SsgDetector.new(Dir.pwd).detect, 29 | }, 30 | ) 31 | loader = Dato::Local::Loader.new(client, preview_mode) 32 | puts "Fetching content from DatoCMS..." 33 | loader.load 34 | 35 | if watch_mode 36 | semaphore = Mutex.new 37 | 38 | thread_safe_dump(semaphore, config_file, client, preview_mode, loader) 39 | 40 | loader.watch do 41 | thread_safe_dump(semaphore, config_file, client, preview_mode, loader) 42 | end 43 | 44 | watch_config_file(config_file) do 45 | thread_safe_dump(semaphore, config_file, client, preview_mode, loader) 46 | end 47 | 48 | sleep 49 | else 50 | Dump::Runner.new(config_file, client, preview_mode, loader).run 51 | end 52 | end 53 | 54 | desc "check", "checks the presence of a DatoCMS token" 55 | def check 56 | exit 0 if ENV["DATO_API_TOKEN"] 57 | 58 | say "Site token is not specified!" 59 | token = ask "Please paste your DatoCMS site read-only API token:\n>" 60 | 61 | if !token || token.empty? 62 | puts "Missing token" 63 | exit 1 64 | end 65 | 66 | File.open(".env", "a") do |file| 67 | file.puts "DATO_API_TOKEN=#{token}" 68 | end 69 | 70 | say "Token added to .env file." 71 | 72 | exit 0 73 | end 74 | 75 | no_tasks do 76 | def watch_config_file(config_file, &block) 77 | Listen.to( 78 | File.dirname(config_file), 79 | only: /#{Regexp.quote(File.basename(config_file))}/, 80 | &block 81 | ).start 82 | end 83 | 84 | def thread_safe_dump(semaphore, config_file, client, preview_mode, loader) 85 | semaphore.synchronize do 86 | Dump::Runner.new(config_file, client, preview_mode, loader).run 87 | end 88 | end 89 | end 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /spec/dato/local/items_repo_integration_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | module Dato 6 | module Local 7 | RSpec.describe ItemsRepo, :vcr do 8 | include_context "with a new site" 9 | 10 | subject(:repo) do 11 | loader = Loader.new(client) 12 | loader.load 13 | loader.items_repo 14 | end 15 | 16 | describe ".to_hash" do 17 | it "dump everything you might need" do 18 | expect(repo.available_locales).to eq %i[en it] 19 | 20 | serialized_site = repo.site.to_hash 21 | expect(serialized_site[:name]).to eq "Integration new test site" 22 | expect(serialized_site[:locales]).to eq %w[en it] 23 | 24 | article_type = repo.item_types.find { |it| it[:api_key] == "article" } 25 | serialized_article = repo.items_of_type(article_type).first.to_hash 26 | expect(serialized_article[:item_type]).to eq "article" 27 | expect(serialized_article[:updated_at]).to be_present 28 | expect(serialized_article[:created_at]).to be_present 29 | expect(serialized_article[:title]).to eq "First post" 30 | expect(serialized_article[:slug]).to eq "first-post" 31 | expect(serialized_article[:image][:format]).to eq "png" 32 | expect(serialized_article[:image][:alt]).to eq "My first post" 33 | expect(serialized_article[:image][:title]).to eq "First post" 34 | expect(serialized_article[:image][:size]).to eq 119_271 35 | expect(serialized_article[:image][:height]).to eq 621 36 | expect(serialized_article[:image][:width]).to eq 2553 37 | expect(serialized_article[:image][:url]).to be_present 38 | expect(serialized_article[:image][:video]).to be_nil 39 | expect(serialized_article[:file][:format]).to eq "txt" 40 | expect(serialized_article[:file][:size]).to eq 10 41 | expect(serialized_article[:file][:url]).to be_present 42 | expect(serialized_article[:file][:video]).to be_nil 43 | expect(serialized_article[:content][:value]).to be_an(Hash) 44 | expect(serialized_article[:content][:blocks].first[:title]).to eq "Foo" 45 | expect(serialized_article[:content][:links].first[:name]).to eq "Mark Smith" 46 | end 47 | end 48 | 49 | context "multi language" do 50 | it "returns localized data correctly" do 51 | I18n.with_locale(:it) do 52 | expect(repo.articles.last.title).to eq "Primo post" 53 | expect(repo.articles.last.to_hash[:title]).to eq "Primo post" 54 | end 55 | 56 | I18n.with_locale(:en) do 57 | expect(repo.articles.last.title).to eq "First post" 58 | expect(repo.articles.last.to_hash[:title]).to eq "First post" 59 | end 60 | end 61 | end 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /spec/dato/upload/file_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | module Dato 6 | module Upload 7 | describe File, :vcr do 8 | let(:account_client) do 9 | generate_account_client! 10 | end 11 | 12 | let(:site) do 13 | account_client.sites.create(name: "Test site") 14 | end 15 | 16 | before { site } 17 | 18 | let(:site_client) do 19 | Dato::Site::Client.new( 20 | site[:readwrite_token], 21 | base_url: ENV.fetch("SITE_API_BASE_URL"), 22 | ) 23 | end 24 | 25 | subject(:upload) do 26 | described_class.new(site_client, source).upload 27 | end 28 | 29 | context "with a url" do 30 | let(:source) { "https://s3.claudiaraddi.net/slideshows/original/4/Sito2.jpg" } 31 | 32 | it "downloads locally and then uploads the file" do 33 | expect(upload).not_to be_nil 34 | expect(site_client.uploads.find(upload[:upload_id])[:format]).to eq "jpg" 35 | end 36 | context "with a 404 url" do 37 | let(:source) { "https://google.it/NonExistentImage.png" } 38 | 39 | it "raise an exception" do 40 | expect { upload }.to raise_error(Faraday::ResourceNotFound) 41 | end 42 | end 43 | end 44 | 45 | context "with a local file" do 46 | let(:source) { "./spec/fixtures/image.jpg" } 47 | 48 | it "uploads the file" do 49 | expect(upload).not_to be_nil 50 | expect(site_client.uploads.find(upload[:upload_id])[:format]).to eq "jpg" 51 | end 52 | 53 | context "jpg without extension" do 54 | let(:source) { "./spec/fixtures/image" } 55 | 56 | it "uploads the file" do 57 | expect(upload).not_to be_nil 58 | expect(site_client.uploads.find(upload[:upload_id])[:format]).to eq "jpeg" 59 | end 60 | end 61 | 62 | context "gif image" do 63 | let(:source) { "./spec/fixtures/image.gif" } 64 | it "uploads the file" do 65 | expect(upload).not_to be_nil 66 | expect(site_client.uploads.find(upload[:upload_id])[:format]).to eq "gif" 67 | end 68 | end 69 | 70 | context "png image" do 71 | let(:source) { "./spec/fixtures/image.png" } 72 | it "uploads the file" do 73 | expect(upload).not_to be_nil 74 | expect(site_client.uploads.find(upload[:upload_id])[:format]).to eq "png" 75 | end 76 | end 77 | 78 | context "no image file" do 79 | let(:source) { "./spec/fixtures/file.txt" } 80 | it "returns format error" do 81 | expect(upload).not_to be_nil 82 | expect(site_client.uploads.find(upload[:upload_id])[:format]).to eq "txt" 83 | end 84 | end 85 | end 86 | end 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /lib/dato/dump/ssg_detector.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "toml" 4 | require "json" 5 | require "yaml" 6 | 7 | module Dato 8 | module Dump 9 | class SsgDetector 10 | attr_reader :path 11 | 12 | RUBY = %w[middleman jekyll nanoc].freeze 13 | 14 | NODE = %w[brunch assemble ember-cli hexo metalsmith react-scripts 15 | roots docpad wintersmith gatsby harp grunt gulp].freeze 16 | 17 | PYTHON = %w[mkdocs pelican cactus].freeze 18 | 19 | HUGO = [ 20 | { 21 | file: "config.toml", 22 | loader: ->(content) { TOML::Parser.new(content).parsed }, 23 | }, 24 | { 25 | file: "config.yaml", 26 | loader: ->(content) { YAML.safe_load(content) }, 27 | }, 28 | { 29 | file: "config.json", 30 | loader: ->(content) { JSON.parse(content) }, 31 | }, 32 | ].freeze 33 | 34 | def initialize(path) 35 | @path = path 36 | end 37 | 38 | def detect 39 | ruby_generator || 40 | node_generator || 41 | python_generator || 42 | hugo || 43 | "unknown" 44 | end 45 | 46 | private 47 | 48 | def ruby_generator 49 | gemfile_path = File.join(path, "Gemfile") 50 | return unless File.exist?(gemfile_path) 51 | 52 | gemfile = File.read(gemfile_path) 53 | 54 | RUBY.find do |generator| 55 | gemfile =~ /('#{generator}'|"#{generator}")/ 56 | end 57 | end 58 | 59 | def node_generator 60 | package_path = File.join(path, "package.json") 61 | return unless File.exist?(package_path) 62 | 63 | package = JSON.parse(File.read(package_path)) 64 | 65 | deps = package.fetch("dependencies", {}) 66 | dev_deps = package.fetch("devDependencies", {}) 67 | all_deps = deps.merge(dev_deps) 68 | 69 | NODE.find do |generator| 70 | all_deps.key? generator 71 | end 72 | rescue JSON::ParserError 73 | nil 74 | end 75 | 76 | def python_generator 77 | requirements_path = File.join(path, "requirements.txt") 78 | return unless File.exist?(requirements_path) 79 | 80 | requirements = File.read(requirements_path) 81 | 82 | PYTHON.find do |generator| 83 | requirements =~ /^#{generator}(==)?/ 84 | end 85 | end 86 | 87 | def hugo 88 | HUGO.any? do |option| 89 | config_path = File.join(path, option[:file]) 90 | if File.exist?(config_path) 91 | config = option[:loader].call(File.read(config_path)) 92 | config.key? "baseurl" 93 | end 94 | end && "hugo" 95 | rescue JSON::ParserError 96 | nil 97 | end 98 | end 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /spec/dato/dump/runner_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "front_matter_parser" 5 | 6 | module Dato 7 | module Dump 8 | RSpec.describe Runner, :vcr do 9 | include_context "with a new site" 10 | 11 | subject(:runner) do 12 | described_class.new(config_path, client, false, loader, destination_path) 13 | end 14 | 15 | let(:config_path) { "./spec/fixtures/config.rb" } 16 | 17 | let(:destination_path) do 18 | Dir.mktmpdir 19 | end 20 | 21 | let(:loader) { Dato::Local::Loader.new(client) } 22 | 23 | describe ".run" do 24 | before do 25 | loader.load 26 | runner.run 27 | end 28 | 29 | it "generates directories and files" do 30 | toml_file = TOML.load(File.read(File.join(destination_path, "foobar.toml"))) 31 | expect(toml_file["sitename"]).to eq "Integration new test site" 32 | 33 | yaml_file = YAML.safe_load(File.read(File.join(destination_path, "site.yml"))) 34 | expect(yaml_file["name"]).to eq "Integration new test site" 35 | expect(yaml_file["locales"]).to eq %w[en it] 36 | 37 | loader = FrontMatterParser::Loader::Yaml.new(allowlist_classes: [Time]) 38 | 39 | article_file = FrontMatterParser::Parser.new(:md, loader: loader).call( 40 | File.read(File.join(destination_path, "posts", "first-post.md")), 41 | ) 42 | expect(article_file.front_matter["item_type"]).to eq "article" 43 | expect(article_file.front_matter["updated_at"]).to be_present 44 | expect(article_file.front_matter["created_at"]).to be_present 45 | expect(article_file.front_matter["title"]).to eq "First post" 46 | expect(article_file.front_matter["slug"]).to eq "first-post" 47 | expect(article_file.front_matter["image"]["format"]).to eq "png" 48 | expect(article_file.front_matter["image"]["size"]).to eq 119_271 49 | expect(article_file.front_matter["image"]["height"]).to eq 621 50 | expect(article_file.front_matter["image"]["width"]).to eq 2553 51 | expect(article_file.front_matter["image"]["url"]).to be_present 52 | expect(article_file.front_matter["image"]["colors"]).to be_present 53 | expect(article_file.front_matter["image"]["tags"]).to eq [] 54 | expect(article_file.front_matter["image"]["smart_tags"]).to include("logo") 55 | expect(article_file.front_matter["image"]["blurhash"]).to be_present 56 | expect(article_file.front_matter["image"]["focal_point"]).to eq("x" => 0.1, "y" => 0.1) 57 | expect(article_file.front_matter["file"]["format"]).to eq "txt" 58 | expect(article_file.front_matter["file"]["size"]).to eq 10 59 | expect(article_file.front_matter["file"]["url"]).to be_present 60 | expect(article_file.front_matter["content"]["value"]).to be_an(Hash) 61 | expect(article_file.front_matter["content"]["blocks"].first["title"]).to eq "Foo" 62 | expect(article_file.front_matter["content"]["links"].first["name"]).to eq "Mark Smith" 63 | 64 | expect(article_file.content).to eq "First post" 65 | end 66 | end 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /spec/dato/utils/meta_tags/description_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | module Dato 6 | module Utils 7 | module MetaTags 8 | describe Description do 9 | include_context "items repo" 10 | 11 | subject(:builder) { described_class.new(item, site) } 12 | 13 | describe "#build" do 14 | let(:result) { builder.build } 15 | let(:description_value) { result[0][:attributes][:content] } 16 | let(:og_value) { result[1][:attributes][:content] } 17 | let(:card_value) { result[2][:attributes][:content] } 18 | 19 | context "with no fallback seo" do 20 | context "with no item" do 21 | it "returns no tags" do 22 | expect(result).to be_nil 23 | end 24 | end 25 | 26 | context "with item" do 27 | let(:item) { items_repo.articles.first } 28 | 29 | context "no SEO" do 30 | it "returns no tags" do 31 | expect(result).to be_nil 32 | end 33 | end 34 | 35 | context "with SEO" do 36 | let(:seo) do 37 | { description: "SEO description" } 38 | end 39 | 40 | it "returns seo description" do 41 | expect(description_value).to eq("SEO description") 42 | expect(og_value).to eq("SEO description") 43 | expect(card_value).to eq("SEO description") 44 | end 45 | end 46 | end 47 | end 48 | 49 | context "with fallback seo" do 50 | let(:global_seo) do 51 | { 52 | fallback_seo: { 53 | description: "Default description", 54 | }, 55 | } 56 | end 57 | 58 | context "with no item" do 59 | it "returns fallback description" do 60 | expect(description_value).to eq("Default description") 61 | expect(og_value).to eq("Default description") 62 | expect(card_value).to eq("Default description") 63 | end 64 | end 65 | 66 | context "with item" do 67 | let(:item) { items_repo.articles.first } 68 | 69 | context "no SEO" do 70 | it "returns fallback description" do 71 | expect(description_value).to eq("Default description") 72 | expect(og_value).to eq("Default description") 73 | expect(card_value).to eq("Default description") 74 | end 75 | end 76 | 77 | context "with SEO" do 78 | let(:seo) do 79 | { description: "SEO description" } 80 | end 81 | 82 | it "returns seo description" do 83 | expect(description_value).to eq("SEO description") 84 | expect(og_value).to eq("SEO description") 85 | expect(card_value).to eq("SEO description") 86 | end 87 | end 88 | end 89 | end 90 | end 91 | end 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 0.7.16 2 | 3 | Fixes bug when launching `dato dump --watch` together with the `environment` option. 4 | 5 | # 0.7.13 6 | 7 | Add option to pass a project's environment: 8 | 9 | ```ruby 10 | require "dato" 11 | client = Dato::Site::Client.new("YOUR-API-KEY", environment: 'sandbox-foobar') 12 | ``` 13 | 14 | # 0.7.12 15 | 16 | Introduces `Dato::Utils::BuildModularBlock` class to help creating modular blocks. 17 | 18 | An example usage can be: 19 | 20 | ```ruby 21 | description = [ 22 | Dato::Utils::BuildModularBlock.build( 23 | item_type: "1235", 24 | name: "Best dog in the world", 25 | year: "2020", 26 | picture: picture 27 | ) 28 | ] 29 | 30 | record = client.items.create( 31 | item_type: "1234", # model ID 32 | name: "Gigio", 33 | description: description 34 | ) 35 | ``` 36 | 37 | Find more info [on the documentation](https://www.datocms.com/docs/content-management-api/resources/item/create). 38 | 39 | # 0.7.11 (not released) 40 | 41 | * Updated specs cassettes after `appeareance -> appearance` typo fix 42 | * Some style changes 43 | 44 | # 0.7.10 45 | 46 | * Fixed SEO title retrieval. Now it fallbacks to default SEO title is item title is blank 47 | 48 | # 0.7.9 49 | 50 | * Added new attributes to uploads 51 | 52 | # 0.7.0 53 | 54 | * Real-time events are now much more granular and the gem avoids downloading all the content every time a change occurs 55 | 56 | # 0.6.18 57 | 58 | * Fixed regression where you could no longer access items' item type 59 | 60 | # 0.6.15 61 | 62 | * Handle `429 Too Many Requests` responses from API 63 | 64 | # 0.6.12 65 | 66 | * Allow empty responses from server 67 | 68 | # 0.6.10 69 | 70 | * Introduced `.meta` on `Dato::Local::Item` to fetch all meta information about the records 71 | 72 | # 0.6.5 73 | 74 | * The `.seo_meta_tags` method now generates fallback titles based on the model field title 75 | 76 | # v0.6.2 77 | 78 | Moved `json_schema` as runtime dependency 79 | 80 | # v0.6.1 81 | 82 | The big change is that the methods the client makes available are generated at runtime based on the [JSON Schema of our CMA](https://www.datocms.com/content-management-api/). This means any new API endpoint — or changes to existing ones — will instantly be reflected to the client, without the need to upgrade to the latest client version. 83 | 84 | We also added a new `deserialize_response` option to every call, that you can use if you want to retrieve the exact payload the DatoCMS returns: 85 | 86 | ```ruby 87 | require "dato" 88 | client = Dato::Site::Client.new("YOUR-API-KEY") 89 | 90 | # `deserialize_response` is true by default: 91 | access_token = client.access_tokens.create(name: "New token", role: "34") 92 | 93 | # { 94 | # "id" => "312", 95 | # "hardcoded_type" => nil, 96 | # "name" => "New token", 97 | # "token" => "XXXX", 98 | # "role" => "34" 99 | # } 100 | 101 | # if `deserialize_response` is false, this will be the result 102 | access_token = client.access_tokens.create({ name: "New token", role: "34" }, deserialize_response: false) 103 | 104 | # { 105 | # "data": { 106 | # "type": "access_token", 107 | # "id": "312", 108 | # "attributes": { 109 | # "name": "New token", 110 | # "token": "XXXX", 111 | # "hardcoded_type": nil 112 | # }, 113 | # "relationships": { 114 | # "role": { 115 | # "data": { 116 | # "type": "role", 117 | # "id": "34" 118 | # } 119 | # } 120 | # } 121 | # } 122 | # } 123 | ``` 124 | 125 | In our doc pages we also added some examples for the super-handy `all_pages` option which was already present since v0.3.29: 126 | 127 | ```ruby 128 | # if you want to fetch all the pages with just one call: 129 | client.items.all({ "filter[type]" => "44" }, all_pages: true) 130 | ``` 131 | -------------------------------------------------------------------------------- /spec/dato/local/json_api_entity_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | module Dato 6 | module Local 7 | RSpec.describe JsonApiEntity do 8 | subject(:object) { described_class.new(payload, data_source) } 9 | let(:payload) do 10 | { 11 | id: "peter", 12 | type: "person", 13 | attributes: { 14 | first_name: "Peter", 15 | last_name: "Griffin", 16 | }, 17 | relationships: { 18 | children: { 19 | data: [ 20 | { type: "person", id: "stewie" }, 21 | ], 22 | }, 23 | mother: { 24 | data: { type: "person", id: "thelma" }, 25 | }, 26 | father: { 27 | data: nil, 28 | }, 29 | }, 30 | } 31 | end 32 | 33 | let(:data_source) do 34 | instance_double("Dato::Local::DataSource") 35 | end 36 | 37 | describe "#id" do 38 | it "returns the object ID" do 39 | expect(object.id).to eq "peter" 40 | end 41 | end 42 | 43 | describe "#type" do 44 | it "returns the object type" do 45 | expect(object.type).to eq "person" 46 | end 47 | end 48 | 49 | describe "attributes" do 50 | it "returns the attribute if it exists" do 51 | expect(object.respond_to?(:first_name)).to be_truthy 52 | expect(object.first_name).to eq "Peter" 53 | end 54 | 55 | it "returns NoMethodError if it doesnt" do 56 | expect(object.respond_to?(:foo_bar)).to be_falsy 57 | expect { object.foo_bar }.to raise_error NoMethodError 58 | end 59 | end 60 | 61 | describe "[]" do 62 | it "returns the attribute if it exists" do 63 | expect(object[:first_name]).to eq "Peter" 64 | end 65 | 66 | it "returns nil if it doesnt" do 67 | expect(object[:foo_bar]).to be_nil 68 | end 69 | end 70 | 71 | describe "==" do 72 | context "same id and type" do 73 | let(:other) do 74 | described_class.new({ id: "peter", type: "person" }, data_source) 75 | end 76 | 77 | it "returns true" do 78 | expect(object == other).to be_truthy 79 | end 80 | end 81 | 82 | context "different id and type" do 83 | let(:other) do 84 | described_class.new({ id: "stewie", type: "person" }, data_source) 85 | end 86 | 87 | it "returns false" do 88 | expect(object == other).to be_falsy 89 | end 90 | end 91 | 92 | context "different class" do 93 | let(:other) do 94 | 12 95 | end 96 | 97 | it "returns false" do 98 | expect(object == other).to be_falsy 99 | end 100 | end 101 | end 102 | 103 | describe "links" do 104 | let(:stewie) { instance_double("Dato::Local::JsonApiObject") } 105 | let(:thelma) { instance_double("Dato::Local::JsonApiObject") } 106 | 107 | before do 108 | allow(data_source).to receive(:find_entity).with("person", "stewie") { stewie } 109 | allow(data_source).to receive(:find_entity).with("person", "thelma") { thelma } 110 | end 111 | 112 | context "multiple linkages" do 113 | it "returns the array of JsonApiObjects" do 114 | expect(object.children).to eq [stewie] 115 | end 116 | end 117 | 118 | context "single linkage" do 119 | it "returns the JsonApiObject" do 120 | expect(object.mother).to eq thelma 121 | expect(object.father).to eq nil 122 | end 123 | end 124 | 125 | it "returns NoMethodError if it does not exist" do 126 | expect { object.xxx }.to raise_error NoMethodError 127 | end 128 | end 129 | end 130 | end 131 | end 132 | -------------------------------------------------------------------------------- /spec/dato/utils/favicon_tags_builder_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | module Dato 6 | module Utils 7 | describe FaviconTagsBuilder do 8 | include_context "items repo" 9 | 10 | subject(:builder) { described_class.new(site, "#ff0000") } 11 | 12 | describe "#meta_tags" do 13 | context "with no favicon" do 14 | let(:favicon) { nil } 15 | 16 | it "returns an array of tags" do 17 | expect(builder.meta_tags).to eq [ 18 | { tag_name: "meta", attributes: { name: "theme-color", content: "#ff0000" } }, 19 | { tag_name: "meta", attributes: { name: "msapplication-TileColor", content: "#ff0000" } }, 20 | { tag_name: "meta", attributes: { name: "application-name", content: "XXX" } }, 21 | ] 22 | end 23 | end 24 | 25 | context "with favicon" do 26 | let(:favicon) { "666" } 27 | 28 | it "returns an array of tags" do 29 | expect(builder.meta_tags).to eq [ 30 | { tag_name: "link", attributes: { sizes: "16x16", type: "image/png", rel: "icon", href: "https://www.datocms-assets.com/seo.png?w=16&h=16" } }, 31 | { tag_name: "link", attributes: { sizes: "32x32", type: "image/png", rel: "icon", href: "https://www.datocms-assets.com/seo.png?w=32&h=32" } }, 32 | { tag_name: "link", attributes: { sizes: "96x96", type: "image/png", rel: "icon", href: "https://www.datocms-assets.com/seo.png?w=96&h=96" } }, 33 | { tag_name: "link", attributes: { sizes: "192x192", type: "image/png", rel: "icon", href: "https://www.datocms-assets.com/seo.png?w=192&h=192" } }, 34 | { tag_name: "link", attributes: { sizes: "57x57", rel: "apple-touch-icon", href: "https://www.datocms-assets.com/seo.png?w=57&h=57" } }, 35 | { tag_name: "link", attributes: { sizes: "60x60", rel: "apple-touch-icon", href: "https://www.datocms-assets.com/seo.png?w=60&h=60" } }, 36 | { tag_name: "link", attributes: { sizes: "72x72", rel: "apple-touch-icon", href: "https://www.datocms-assets.com/seo.png?w=72&h=72" } }, 37 | { tag_name: "link", attributes: { sizes: "76x76", rel: "apple-touch-icon", href: "https://www.datocms-assets.com/seo.png?w=76&h=76" } }, 38 | { tag_name: "link", attributes: { sizes: "114x114", rel: "apple-touch-icon", href: "https://www.datocms-assets.com/seo.png?w=114&h=114" } }, 39 | { tag_name: "link", attributes: { sizes: "120x120", rel: "apple-touch-icon", href: "https://www.datocms-assets.com/seo.png?w=120&h=120" } }, 40 | { tag_name: "link", attributes: { sizes: "144x144", rel: "apple-touch-icon", href: "https://www.datocms-assets.com/seo.png?w=144&h=144" } }, 41 | { tag_name: "link", attributes: { sizes: "152x152", rel: "apple-touch-icon", href: "https://www.datocms-assets.com/seo.png?w=152&h=152" } }, 42 | { tag_name: "link", attributes: { sizes: "180x180", rel: "apple-touch-icon", href: "https://www.datocms-assets.com/seo.png?w=180&h=180" } }, 43 | { tag_name: "meta", attributes: { name: "msapplication-square70x70logo", content: "https://www.datocms-assets.com/seo.png?w=70&h=70" } }, 44 | { tag_name: "meta", attributes: { name: "msapplication-square150x150logo", content: "https://www.datocms-assets.com/seo.png?w=150&h=150" } }, 45 | { tag_name: "meta", attributes: { name: "msapplication-square310x310logo", content: "https://www.datocms-assets.com/seo.png?w=310&h=310" } }, 46 | { tag_name: "meta", attributes: { name: "msapplication-square310x150logo", content: "https://www.datocms-assets.com/seo.png?w=310&h=150" } }, 47 | { tag_name: "meta", attributes: { name: "theme-color", content: "#ff0000" } }, 48 | { tag_name: "meta", attributes: { name: "msapplication-TileColor", content: "#ff0000" } }, 49 | { tag_name: "meta", attributes: { name: "application-name", content: "XXX" } }, 50 | ] 51 | end 52 | end 53 | end 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/dato/repo.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "dato/json_api_serializer" 4 | require "dato/json_api_deserializer" 5 | require "dato/paginator" 6 | 7 | module Dato 8 | class Repo 9 | attr_reader :client, :type, :schema 10 | 11 | IDENTITY_REGEXP = /\{\(.*?definitions%2F(.*?)%2Fdefinitions%2Fidentity\)}/.freeze 12 | 13 | METHOD_NAMES = { 14 | "instances" => :all, 15 | "self" => :find, 16 | }.freeze 17 | 18 | def initialize(client, type, schema) 19 | @client = client 20 | @type = type 21 | @schema = schema 22 | end 23 | 24 | def respond_to_missing?(method, include_private = false) 25 | respond_to_missing = schema.links.any? do |link| 26 | METHOD_NAMES.fetch(link.rel, link.rel).to_sym == method.to_sym 27 | end 28 | 29 | respond_to_missing || super 30 | end 31 | 32 | private 33 | 34 | def method_missing(method, *args, &block) 35 | link = schema.links.find do |ilink| 36 | METHOD_NAMES.fetch(ilink.rel, ilink.rel).to_sym == method.to_sym 37 | end 38 | 39 | return super unless link 40 | 41 | min_arguments_count = [ 42 | link.href.scan(IDENTITY_REGEXP).size, 43 | link.schema && link.method != :get ? 1 : 0, 44 | ].reduce(0, :+) 45 | 46 | (args.size >= min_arguments_count) || 47 | raise(ArgumentError, "wrong number of arguments (given #{args.size}, expected #{min_arguments_count})") 48 | 49 | placeholders = [] 50 | 51 | url = link["href"].gsub(IDENTITY_REGEXP) do |_stuff| 52 | placeholder = args.shift.to_s 53 | placeholders << placeholder 54 | placeholder 55 | end 56 | 57 | body = nil 58 | query_string = nil 59 | 60 | if %i[post put].include?(link.method) 61 | body = link.schema ? args.shift : {} 62 | query_string = args.shift || {} 63 | 64 | elsif %i[get delete].include?(link.method) 65 | query_string = args.shift || {} 66 | end 67 | 68 | options = args.any? ? args.shift.symbolize_keys : {} 69 | 70 | if link.schema && %i[post put].include?(link.method) && options.fetch(:serialize_response, true) 71 | body = JsonApiSerializer.new(link: link).serialize( 72 | body, 73 | link.method == :post ? nil : placeholders.last, 74 | ) 75 | end 76 | 77 | response = if %i[post put].include?(link.method) 78 | client.send(link.method, url, body, query_string) 79 | elsif link.method == :delete 80 | client.delete(url, query_string) 81 | elsif link.method == :get 82 | if options.fetch(:all_pages, false) 83 | Paginator.new(client, url, query_string).response 84 | else 85 | client.get(url, query_string) 86 | end 87 | end 88 | 89 | if response && response[:data] && response[:data].is_a?(Hash) && response[:data][:type] == "job" 90 | job_result = nil 91 | 92 | until job_result 93 | begin 94 | sleep(1) 95 | job_result = client.job_result.find(response[:data][:id]) 96 | rescue ApiError => e 97 | raise e if e.response[:status] != 404 98 | end 99 | end 100 | 101 | if job_result[:status] < 200 || job_result[:status] >= 300 102 | error = ApiError.new( 103 | status: job_result[:status], 104 | body: JSON.dump(job_result[:payload]), 105 | ) 106 | 107 | puts "====" 108 | puts error.message 109 | puts "====" 110 | 111 | raise error 112 | end 113 | 114 | if options.fetch(:deserialize_response, true) 115 | JsonApiDeserializer.new(link.job_schema).deserialize(job_result[:payload]) 116 | else 117 | job_result.payload 118 | end 119 | elsif options.fetch(:deserialize_response, true) 120 | JsonApiDeserializer.new(link.target_schema).deserialize(response) 121 | else 122 | response 123 | end 124 | end 125 | end 126 | end 127 | -------------------------------------------------------------------------------- /lib/dato/json_api_serializer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "dato/json_schema_relationships" 4 | require "dato/json_schema_type" 5 | 6 | module Dato 7 | class JsonApiSerializer 8 | attr_reader :link, :type 9 | 10 | def initialize(type: nil, link: nil) 11 | @link = link 12 | @type = type || type_from_schema 13 | end 14 | 15 | def serialize(resource, id = nil) 16 | resource = resource.with_indifferent_access 17 | data = {} 18 | 19 | data[:id] = id || resource[:id] if id || resource[:id] 20 | 21 | resource.delete(:meta) if resource.key?(:meta) 22 | 23 | data[:type] = type 24 | 25 | if link.schema && 26 | link.schema.properties["data"] && 27 | link.schema.properties["data"].properties.keys.include?("attributes") 28 | 29 | serialized_resource_attributes = serialized_attributes(resource) 30 | data[:attributes] = serialized_resource_attributes 31 | end 32 | 33 | serialized_relationships = serialized_relationships(resource) 34 | 35 | data[:relationships] = serialized_relationships if serialized_relationships 36 | 37 | { data: data } 38 | end 39 | 40 | def serialized_attributes(resource) 41 | result = {} 42 | 43 | attributes(resource).each do |attribute| 44 | if resource.key? attribute 45 | result[attribute] = resource[attribute] 46 | elsif required_attributes.include? attribute 47 | throw "Required attribute: #{attribute}" 48 | end 49 | end 50 | 51 | result 52 | end 53 | 54 | def serialized_relationships(resource) 55 | result = {} 56 | 57 | relationships.each do |relationship, meta| 58 | if resource.key? relationship 59 | value = resource[relationship] 60 | 61 | data = if value 62 | if meta[:types].length > 1 63 | if meta[:collection] 64 | value.map(&:symbolize_keys) 65 | else 66 | value.symbolize_keys 67 | end 68 | else 69 | meta_type = meta[:types].first 70 | if meta[:collection] 71 | value.map do |id| 72 | { 73 | type: meta_type, 74 | id: id.to_s, 75 | } 76 | end 77 | else 78 | { 79 | type: meta_type, 80 | id: value.to_s, 81 | } 82 | end 83 | end 84 | end 85 | 86 | result[relationship] = { data: data } 87 | elsif required_relationships.include?(relationship) 88 | throw "Required attribute: #{relationship}" 89 | end 90 | end 91 | 92 | result.empty? ? nil : result 93 | end 94 | 95 | def attributes(resource) 96 | if type == "item" 97 | return resource.keys.reject do |key| 98 | %i[ 99 | item_type 100 | id 101 | created_at 102 | updated_at 103 | creator 104 | ].include?(key.to_sym) 105 | end 106 | end 107 | 108 | link_attributes["properties"].keys.map(&:to_sym) 109 | end 110 | 111 | def required_attributes 112 | return [] if type == "item" 113 | 114 | (link_attributes.required || []).map(&:to_sym) 115 | end 116 | 117 | def relationships 118 | @relationships ||= JsonSchemaRelationships.new(link.schema).relationships 119 | end 120 | 121 | def required_relationships 122 | if link.schema.properties["data"].required.include?("relationships") 123 | (link_relationships.required || []).map(&:to_sym) 124 | else 125 | [] 126 | end 127 | end 128 | 129 | def link_attributes 130 | link.schema.properties["data"].properties["attributes"] 131 | end 132 | 133 | def link_relationships 134 | link.schema.properties["data"].properties["relationships"] 135 | end 136 | 137 | def type_from_schema 138 | Dato::JsonSchemaType.new(link.schema).call 139 | end 140 | end 141 | end 142 | -------------------------------------------------------------------------------- /spec/dato/utils/meta_tags/image_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | module Dato 6 | module Utils 7 | module MetaTags 8 | describe Image do 9 | include_context "items repo" 10 | 11 | subject(:builder) { described_class.new(item, site) } 12 | 13 | describe "#build" do 14 | let(:result) { builder.build } 15 | let(:og_value) { result[0][:attributes][:content] } 16 | let(:card_value) { result[1][:attributes][:content] } 17 | 18 | context "with no fallback seo" do 19 | context "with no item" do 20 | it "returns no tags" do 21 | expect(result).to be_nil 22 | end 23 | end 24 | 25 | context "with item" do 26 | let(:item) { items_repo.articles.first } 27 | 28 | context "with no image" do 29 | context "no SEO" do 30 | it "returns no tags" do 31 | expect(result).to be_nil 32 | end 33 | end 34 | 35 | context "with SEO" do 36 | let(:seo) do 37 | { 38 | image: "666", 39 | } 40 | end 41 | 42 | it "returns seo image" do 43 | expect(og_value).to include("seo.png") 44 | expect(card_value).to include("seo.png") 45 | end 46 | end 47 | end 48 | 49 | context "with image" do 50 | let(:item_image) { "111" } 51 | 52 | context "no SEO" do 53 | it "returns item image" do 54 | expect(og_value).to include("image.png") 55 | expect(card_value).to include("image.png") 56 | end 57 | end 58 | 59 | context "with SEO" do 60 | let(:seo) do 61 | { 62 | image: "666", 63 | } 64 | end 65 | 66 | it "returns SEO image" do 67 | expect(og_value).to include("seo.png") 68 | expect(card_value).to include("seo.png") 69 | end 70 | end 71 | end 72 | end 73 | 74 | context "with fallback seo" do 75 | let(:global_seo) do 76 | { 77 | fallback_seo: { 78 | image: "999", 79 | }, 80 | } 81 | end 82 | 83 | context "with no item" do 84 | it "returns fallback image" do 85 | expect(og_value).to include("fallback_seo.png") 86 | expect(card_value).to include("fallback_seo.png") 87 | end 88 | end 89 | 90 | context "with item" do 91 | let(:item) { items_repo.articles.first } 92 | 93 | context "with no image" do 94 | context "no SEO" do 95 | it "returns fallback image" do 96 | expect(og_value).to include("fallback_seo.png") 97 | expect(card_value).to include("fallback_seo.png") 98 | end 99 | end 100 | 101 | context "with SEO" do 102 | let(:seo) do 103 | { 104 | image: "666", 105 | } 106 | end 107 | 108 | it "returns seo image" do 109 | expect(og_value).to include("seo.png") 110 | expect(card_value).to include("seo.png") 111 | end 112 | end 113 | end 114 | 115 | context "with image" do 116 | let(:item_image) { "111" } 117 | 118 | context "no SEO" do 119 | it "returns item image" do 120 | expect(og_value).to include("image.png") 121 | expect(card_value).to include("image.png") 122 | end 123 | end 124 | 125 | context "with SEO" do 126 | let(:seo) do 127 | { 128 | image: "666", 129 | } 130 | end 131 | 132 | it "returns SEO image" do 133 | expect(og_value).to include("seo.png") 134 | expect(card_value).to include("seo.png") 135 | end 136 | end 137 | end 138 | end 139 | end 140 | end 141 | end 142 | end 143 | end 144 | end 145 | end 146 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 👉 [Visit the DatoCMS homepage](https://www.datocms.com) or see [What is DatoCMS?](#what-is-datocms) 5 | 6 | 7 | # DatoCMS Ruby Client 8 | 9 | [![Coverage Status](https://coveralls.io/repos/github/datocms/ruby-datocms-client/badge.svg?branch=master)](https://coveralls.io/github/datocms/ruby-datocms-client?branch=master) [![Build Status](https://travis-ci.org/datocms/ruby-datocms-client.svg?branch=master)](https://travis-ci.org/datocms/ruby-datocms-client) [![Gem Version](https://badge.fury.io/rb/dato.svg)](https://badge.fury.io/rb/dato) 10 | 11 | CLI tool for DatoCMS (https://www.datocms.com). 12 | 13 | ## How to integrate DatoCMS with Jekyll 14 | 15 | Please head over the [Jekyll section of our documentation](https://www.datocms.com/docs/jekyll/) to learn everything you need to get started. 16 | 17 | ## How to integrate DatoCMS with Middleman 18 | 19 | For Middleman we have created a nice Middleman extension called [middleman-dato](https://github.com/datocms/middleman-dato). Please visit the [Middleman section of our documentation](https://docs.datocms.com/middleman/overview.html) to learn everything you need to get started. 20 | 21 | ## API Client 22 | 23 | This gem also exposes an API client, useful ie. to import existing content in your DatoCMS administrative area. Read our [documentation](https://www.datocms.com/content-management-api/) for detailed info. 24 | 25 | ## Development 26 | 27 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 28 | 29 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). 30 | 31 | ## Contributing 32 | 33 | Bug reports and pull requests are welcome on GitHub at https://github.com/datocms/ruby-datocms-client. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct. 34 | 35 | ## License 36 | 37 | The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). 38 | 39 | 40 | 41 | ----------------- 42 | # What is DatoCMS? 43 | 44 | 45 | [DatoCMS](https://www.datocms.com/) is the REST & GraphQL Headless CMS for the modern web. 46 | 47 | Trusted by over 25,000 enterprise businesses, agency partners, and individuals across the world, DatoCMS users create online content at scale from a central hub and distribute it via API. We ❤️ our [developers](https://www.datocms.com/team/best-cms-for-developers), [content editors](https://www.datocms.com/team/content-creators) and [marketers](https://www.datocms.com/team/cms-digital-marketing)! 48 | 49 | **Quick links:** 50 | 51 | - ⚡️ Get started with a [free DatoCMS account](https://dashboard.datocms.com/signup) 52 | - 🔖 Go through the [docs](https://www.datocms.com/docs) 53 | - ⚙️ Get [support from us and the community](https://community.datocms.com/) 54 | - 🆕 Stay up to date on new features and fixes on the [changelog](https://www.datocms.com/product-updates) 55 | 56 | **Our featured repos:** 57 | - [datocms/react-datocms](https://github.com/datocms/react-datocms): React helper components for images, Structured Text rendering, and more 58 | - [datocms/js-rest-api-clients](https://github.com/datocms/js-rest-api-clients): Node and browser JavaScript clients for updating and administering your content. For frontend fetches, we recommend using our [GraphQL Content Delivery API](https://www.datocms.com/docs/content-delivery-api) instead. 59 | - [datocms/cli](https://github.com/datocms/cli): Command-line interface that includes our [Contentful importer](https://github.com/datocms/cli/tree/main/packages/cli-plugin-contentful) and [Wordpress importer](https://github.com/datocms/cli/tree/main/packages/cli-plugin-wordpress) 60 | - [datocms/plugins](https://github.com/datocms/plugins): Example plugins we've made that extend the editor/admin dashboard 61 | - [datocms/gatsby-source-datocms](https://github.com/datocms/gatsby-source-datocms): Our Gatsby source plugin to pull data from DatoCMS 62 | - Frontend examples in different frameworks: [Next.js](https://github.com/datocms/nextjs-demo), [Vue](https://github.com/datocms/vue-datocms) and [Nuxt](https://github.com/datocms/nuxtjs-demo), [Svelte](https://github.com/datocms/datocms-svelte) and [SvelteKit](https://github.com/datocms/sveltekit-demo), [Astro](https://github.com/datocms/datocms-astro-blog-demo), [Remix](https://github.com/datocms/remix-example). See [all our starter templates](https://www.datocms.com/marketplace/starters). 63 | 64 | Or see [all our public repos](https://github.com/orgs/datocms/repositories?q=&type=public&language=&sort=stargazers) 65 | 66 | -------------------------------------------------------------------------------- /lib/dato/local/item.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "forwardable" 4 | require "dato/utils/locale_value" 5 | 6 | Dir["#{File.dirname(__FILE__)}/field_type/*.rb"].sort.each do |file| 7 | require file 8 | end 9 | 10 | module Dato 11 | module Local 12 | class Item 13 | extend Forwardable 14 | 15 | attr_reader :entity 16 | 17 | def_delegators :entity, :id, :meta 18 | 19 | def initialize(entity, items_repo) 20 | @entity = entity 21 | @items_repo = items_repo 22 | end 23 | 24 | def ==(other) 25 | other.is_a?(Item) && other.id == id 26 | end 27 | 28 | def seo_meta_tags 29 | Utils::SeoTagsBuilder.new(self, @items_repo.site).meta_tags 30 | end 31 | 32 | def singleton? 33 | item_type.singleton 34 | end 35 | alias single_instance? singleton? 36 | 37 | def item_type 38 | @item_type ||= entity.item_type 39 | end 40 | 41 | def fields 42 | @fields ||= item_type.fields.sort_by(&:position) 43 | end 44 | 45 | def attributes 46 | fields.each_with_object( 47 | ActiveSupport::HashWithIndifferentAccess.new, 48 | ) do |field, acc| 49 | acc[field.api_key.to_sym] = read_attribute(field.api_key, field) 50 | end 51 | end 52 | 53 | def position 54 | entity.position 55 | end 56 | 57 | def parent 58 | @items_repo.find(entity.parent_id) if item_type.tree && entity.parent_id 59 | end 60 | 61 | def children 62 | @items_repo.children_of(id).sort_by(&:position) if item_type.tree 63 | end 64 | 65 | def updated_at 66 | Time.parse(meta.updated_at).utc 67 | end 68 | 69 | def created_at 70 | Time.parse(meta.created_at).utc 71 | end 72 | 73 | def to_s 74 | api_key = item_type.api_key 75 | "#" 76 | end 77 | alias inspect to_s 78 | 79 | def [](key) 80 | attributes[key.to_sym] 81 | end 82 | 83 | def to_hash(max_depth = 3, current_depth = 0) 84 | return id if current_depth >= max_depth 85 | 86 | base = { 87 | id: id, 88 | item_type: item_type.api_key, 89 | updated_at: updated_at, 90 | created_at: created_at, 91 | } 92 | 93 | base[:position] = position if item_type.sortable 94 | 95 | if item_type.tree 96 | base[:position] = position 97 | base[:children] = children.map do |child| 98 | child.to_hash( 99 | max_depth, 100 | current_depth + 1, 101 | ) 102 | end 103 | end 104 | 105 | fields.each_with_object(base) do |field, result| 106 | value = send(field.api_key) 107 | 108 | result[field.api_key.to_sym] = if value.respond_to?(:to_hash) 109 | value.to_hash( 110 | max_depth, 111 | current_depth + 1, 112 | ) 113 | else 114 | value 115 | end 116 | end 117 | end 118 | 119 | private 120 | 121 | def read_attribute(method, field) 122 | field_type = field.field_type 123 | type_klass_name = "::Dato::Local::FieldType::#{field_type.camelize}" 124 | type_klass = type_klass_name.safe_constantize 125 | 126 | value = if field.localized 127 | obj = entity[method] || {} 128 | Utils::LocaleValue.find(obj) 129 | else 130 | entity[method] 131 | end 132 | 133 | if type_klass 134 | type_klass.parse(value, @items_repo) 135 | else 136 | warning = [ 137 | "Warning: unrecognized field of type `#{field_type}`", 138 | "for item `#{item_type.api_key}` and", 139 | "field `#{method}`: returning a simple Hash instead.", 140 | "Please upgrade to the latest version of the `dato` gem!", 141 | ] 142 | puts warning.join(" ") 143 | 144 | value 145 | end 146 | end 147 | 148 | def method_missing(method, *arguments, &block) 149 | field = fields.find { |f| f.api_key.to_sym == method } 150 | if field && arguments.empty? 151 | read_attribute(method, field) 152 | else 153 | super 154 | end 155 | rescue NoMethodError => e 156 | if e.name == method 157 | message = [] 158 | message << "Undefined method `#{method}`" 159 | message << "Available fields for a `#{item_type.api_key}` item:" 160 | message += fields.map do |f| 161 | "* .#{f.api_key}" 162 | end 163 | raise NoMethodError, message.join("\n") 164 | else 165 | raise e 166 | end 167 | end 168 | 169 | def respond_to_missing?(method, include_private = false) 170 | field = fields.find { |f| f.api_key.to_sym == method } 171 | if field 172 | true 173 | else 174 | super 175 | end 176 | end 177 | end 178 | end 179 | end 180 | -------------------------------------------------------------------------------- /lib/dato/api_client.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "faraday" 4 | require "faraday_middleware" 5 | require "json" 6 | require "json_schema" 7 | 8 | require "dato/version" 9 | require "dato/repo" 10 | 11 | require "dato/api_error" 12 | 13 | require "cacert" 14 | 15 | module Dato 16 | module ApiClient 17 | def self.included(base) 18 | base.extend ClassMethods 19 | 20 | base.class_eval do 21 | attr_reader :token, :environment, :base_url, :schema, :extra_headers 22 | end 23 | end 24 | 25 | module ClassMethods 26 | def json_schema(subdomain) 27 | define_method(:initialize) do |token, options = {}| 28 | @token = token 29 | @base_url = options[:base_url] || "https://#{subdomain}.datocms.com" 30 | @environment = options[:environment] 31 | @extra_headers = options[:extra_headers] || {} 32 | end 33 | 34 | define_singleton_method(:subdomain) do 35 | subdomain 36 | end 37 | end 38 | end 39 | 40 | def respond_to_missing?(method, include_private = false) 41 | json_schema.definitions.each do |type, obj| 42 | is_collection = obj.links.select { |x| x.rel == "instances" }.any? 43 | namespace = is_collection ? type.pluralize : type 44 | return true if method.to_s == namespace 45 | end 46 | 47 | super 48 | end 49 | 50 | def method_missing(method, *args, &block) 51 | json_schema.definitions.each do |type, obj| 52 | is_collection = obj.links.select { |x| x.rel == "instances" }.any? 53 | namespace = is_collection ? type.pluralize : type 54 | 55 | next unless method.to_s == namespace 56 | 57 | instance_variable_set( 58 | "@#{namespace}", 59 | instance_variable_get("@#{namespace}") || 60 | Dato::Repo.new(self, type, obj), 61 | ) 62 | 63 | return instance_variable_get("@#{namespace}") 64 | end 65 | 66 | super 67 | end 68 | 69 | def json_schema 70 | @json_schema ||= begin 71 | response = Faraday.get( 72 | # "http://#{subdomain}.lvh.me:3001/docs/#{subdomain}-hyperschema.json" 73 | "#{base_url}/docs/#{self.class.subdomain}-hyperschema.json", 74 | ) 75 | 76 | schema = JsonSchema.parse!(JSON.parse(response.body)) 77 | schema.expand_references! 78 | 79 | schema 80 | end 81 | end 82 | 83 | def put(absolute_path, body = {}, params = {}) 84 | request(:put, absolute_path, body, params) 85 | end 86 | 87 | def post(absolute_path, body = {}, params = {}) 88 | request(:post, absolute_path, body, params) 89 | end 90 | 91 | def get(absolute_path, params = {}) 92 | request(:get, absolute_path, nil, params) 93 | end 94 | 95 | def delete(absolute_path, params = {}) 96 | request(:delete, absolute_path, nil, params) 97 | end 98 | 99 | def request(*args) 100 | method, absolute_path, body, params = args 101 | 102 | response = connection.send(method, absolute_path, body) do |c| 103 | c.params = params if params 104 | end 105 | 106 | response.body.with_indifferent_access if response.body.is_a?(Hash) 107 | rescue Faraday::SSLError => e 108 | raise e if ENV["SSL_CERT_FILE"] == Cacert.pem 109 | 110 | Cacert.set_in_env 111 | request(*args) 112 | rescue Faraday::ConnectionFailed, Faraday::TimeoutError => e 113 | puts e.message 114 | raise e 115 | rescue Faraday::ClientError => e 116 | if e.response[:status] == 429 117 | to_wait = e.response[:headers]["x-ratelimit-reset"].to_i 118 | puts "Rate limit exceeded, waiting #{to_wait} seconds..." 119 | sleep(to_wait + 1) 120 | request(*args) 121 | elsif e.response[:status] == 422 && batch_data_validation?(e.response) 122 | puts "Validating items, waiting 1 second and retrying..." 123 | sleep(1) 124 | request(*args) 125 | else 126 | # puts body.inspect 127 | # puts '====' 128 | # puts error.message 129 | # puts '====' 130 | error = ApiError.new(e.response) 131 | raise error 132 | end 133 | end 134 | 135 | private 136 | 137 | def batch_data_validation?(response) 138 | body = begin 139 | JSON.parse(response[:body]) 140 | rescue JSON::ParserError 141 | nil 142 | end 143 | 144 | return false unless body 145 | return false unless body["data"] 146 | 147 | body["data"].any? do |e| 148 | e["attributes"]["code"] == "BATCH_DATA_VALIDATION_IN_PROGRESS" 149 | end 150 | rescue StandardError 151 | false 152 | end 153 | 154 | def connection 155 | default_headers = { 156 | "Accept" => "application/json", 157 | "Content-Type" => "application/json", 158 | "Authorization" => "Bearer #{@token}", 159 | "User-Agent" => "ruby-client v#{Dato::VERSION}", 160 | "X-Api-Version" => "3", 161 | } 162 | 163 | default_headers.merge!("X-Environment" => environment) if environment 164 | 165 | options = { 166 | url: base_url, 167 | headers: default_headers.merge(extra_headers), 168 | } 169 | 170 | @connection ||= Faraday.new(options) do |c| 171 | c.request :json 172 | c.response :json, content_type: /\bjson$/ 173 | c.response :raise_error 174 | c.use FaradayMiddleware::FollowRedirects 175 | c.adapter :net_http 176 | end 177 | end 178 | end 179 | end 180 | -------------------------------------------------------------------------------- /spec/dato/utils/meta_tags/title_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | module Dato 6 | module Utils 7 | module MetaTags 8 | describe Title do 9 | include_context "items repo" 10 | 11 | subject(:builder) { described_class.new(item, site) } 12 | 13 | describe "#build" do 14 | let(:result) { builder.build } 15 | let(:title_tag) { result[0][:content] } 16 | let(:og_value) { result[1][:attributes][:content] } 17 | let(:card_value) { result[2][:attributes][:content] } 18 | 19 | context "with no fallback seo" do 20 | context "with no item" do 21 | it "returns no tags" do 22 | expect(result).to be_nil 23 | end 24 | end 25 | 26 | context "with item" do 27 | let(:item) { items_repo.articles.first } 28 | 29 | context "with no title" do 30 | context "no SEO" do 31 | it "returns no tags" do 32 | expect(result).to be_nil 33 | end 34 | end 35 | 36 | context "with SEO" do 37 | let(:seo) do 38 | { title: "SEO title" } 39 | end 40 | 41 | it "returns seo title" do 42 | expect(title_tag).to eq("SEO title") 43 | expect(og_value).to eq("SEO title") 44 | expect(card_value).to eq("SEO title") 45 | end 46 | end 47 | end 48 | 49 | context "with title" do 50 | let(:item_title) { "My title" } 51 | 52 | context "no SEO" do 53 | it "returns item title" do 54 | expect(title_tag).to eq("My title") 55 | expect(og_value).to eq("My title") 56 | expect(card_value).to eq("My title") 57 | end 58 | end 59 | 60 | context "with SEO" do 61 | let(:seo) do 62 | { title: "SEO title" } 63 | end 64 | 65 | it "returns SEO title" do 66 | expect(title_tag).to eq("SEO title") 67 | expect(og_value).to eq("SEO title") 68 | expect(card_value).to eq("SEO title") 69 | end 70 | end 71 | end 72 | end 73 | end 74 | 75 | context "with fallback seo" do 76 | let(:global_seo) do 77 | { 78 | title_suffix: title_suffix, 79 | fallback_seo: { 80 | title: "Default title", 81 | }, 82 | } 83 | end 84 | 85 | context "with no item" do 86 | context "with title suffix" do 87 | let(:title_suffix) { " - My site" } 88 | 89 | it "returns fallback title" do 90 | expect(title_tag).to eq("Default title - My site") 91 | expect(og_value).to eq("Default title") 92 | expect(card_value).to eq("Default title") 93 | end 94 | end 95 | 96 | context "without title suffix" do 97 | it "returns fallback title" do 98 | expect(title_tag).to eq("Default title") 99 | expect(og_value).to eq("Default title") 100 | expect(card_value).to eq("Default title") 101 | end 102 | end 103 | end 104 | 105 | context "with item" do 106 | let(:item) { items_repo.articles.first } 107 | 108 | context "with no title" do 109 | context "no SEO" do 110 | it "returns fallback title" do 111 | expect(title_tag).to eq("Default title") 112 | expect(og_value).to eq("Default title") 113 | expect(card_value).to eq("Default title") 114 | end 115 | end 116 | 117 | context "with SEO" do 118 | let(:seo) do 119 | { title: "SEO title" } 120 | end 121 | 122 | it "returns seo title" do 123 | expect(title_tag).to eq("SEO title") 124 | expect(og_value).to eq("SEO title") 125 | expect(card_value).to eq("SEO title") 126 | end 127 | end 128 | end 129 | 130 | context "with title" do 131 | let(:item_title) { "My title" } 132 | 133 | context "no SEO" do 134 | it "returns item title" do 135 | expect(title_tag).to eq("My title") 136 | expect(og_value).to eq("My title") 137 | expect(card_value).to eq("My title") 138 | end 139 | end 140 | 141 | context "with SEO" do 142 | let(:seo) do 143 | { title: "SEO title" } 144 | end 145 | 146 | it "returns SEO title" do 147 | expect(title_tag).to eq("SEO title") 148 | expect(og_value).to eq("SEO title") 149 | expect(card_value).to eq("SEO title") 150 | end 151 | end 152 | end 153 | 154 | context "with blank title" do 155 | let(:item_title) { "" } 156 | 157 | context "no SEO" do 158 | it "returns fallback title" do 159 | expect(title_tag).to eq("Default title") 160 | expect(og_value).to eq("Default title") 161 | expect(card_value).to eq("Default title") 162 | end 163 | end 164 | 165 | context "with SEO" do 166 | let(:seo) do 167 | { title: "SEO title" } 168 | end 169 | 170 | it "returns SEO title" do 171 | expect(title_tag).to eq("SEO title") 172 | expect(og_value).to eq("SEO title") 173 | expect(card_value).to eq("SEO title") 174 | end 175 | end 176 | end 177 | end 178 | end 179 | end 180 | end 181 | end 182 | end 183 | end 184 | -------------------------------------------------------------------------------- /lib/dato/local/loader.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "pusher-client" 4 | 5 | require "dato/local/entities_repo" 6 | require "dato/local/items_repo" 7 | 8 | module Dato 9 | module Local 10 | class Loader 11 | attr_reader :client, :entities_repo, :items_repo, :preview_mode 12 | 13 | PUSHER_API_KEY = "75e6ef0fe5d39f481626" 14 | 15 | # rubocop:disable Style/OptionalBooleanParameter 16 | def initialize(client, preview_mode = false) 17 | @client = client 18 | @preview_mode = preview_mode 19 | @entities_repo = EntitiesRepo.new 20 | @items_repo = ItemsRepo.new(@entities_repo) 21 | end 22 | # rubocop:enable Style/OptionalBooleanParameter 23 | 24 | def load 25 | threads = [ 26 | Thread.new { Thread.current[:output] = site }, 27 | Thread.new { Thread.current[:output] = all_items }, 28 | Thread.new { Thread.current[:output] = all_uploads }, 29 | ] 30 | 31 | results = threads.map do |t| 32 | t.join 33 | t[:output] 34 | end 35 | 36 | @entities_repo = EntitiesRepo.new(*results) 37 | @items_repo = ItemsRepo.new(@entities_repo) 38 | end 39 | 40 | def watch(&block) 41 | site_id = client.get("/site")["data"]["id"] 42 | 43 | return if pusher && pusher.connected 44 | 45 | channel_name = if client.environment 46 | "private-site-#{site_id}-environment-#{client.environment}" 47 | else 48 | "private-site-#{site_id}" 49 | end 50 | 51 | pusher.subscribe(channel_name) 52 | 53 | bind_on_site_upsert(&block) 54 | bind_on_item_destroy(&block) 55 | bind_on_item_upsert(&block) 56 | bind_on_item_type_upsert(&block) 57 | bind_on_item_type_destroy(&block) 58 | bind_on_upload_upsert(&block) 59 | bind_on_upload_destroy(&block) 60 | 61 | pusher.connect(true) 62 | end 63 | 64 | def stop_watch 65 | pusher.disconnect if pusher && pusher.connected 66 | end 67 | 68 | private 69 | 70 | def bind_on_site_upsert(&block) 71 | bind_on("site:upsert", block) do |_data| 72 | threads = [ 73 | Thread.new { Thread.current[:output] = site }, 74 | Thread.new { Thread.current[:output] = all_items }, 75 | Thread.new { Thread.current[:output] = all_uploads }, 76 | ] 77 | 78 | results = threads.map do |t| 79 | t.join 80 | t[:output] 81 | end 82 | 83 | @entities_repo = EntitiesRepo.new(*results) 84 | end 85 | end 86 | 87 | def bind_on_item_upsert(&block) 88 | event_type = preview_mode ? "preview_mode" : "published_mode" 89 | 90 | bind_on("item:#{event_type}:upsert", block) do |data| 91 | payload = client.items.all( 92 | { 93 | "filter[ids]" => data[:ids].join(","), 94 | version: item_version, 95 | }, 96 | deserialize_response: false, 97 | all_pages: true, 98 | ) 99 | 100 | @entities_repo.upsert_entities(payload) 101 | end 102 | end 103 | 104 | def bind_on_item_destroy(&block) 105 | event_type = preview_mode ? "preview_mode" : "published_mode" 106 | 107 | bind_on("item:#{event_type}:destroy", block) do |data| 108 | @entities_repo.destroy_entities("item", data[:ids]) 109 | end 110 | end 111 | 112 | def bind_on_upload_upsert(&block) 113 | bind_on("upload:upsert", block) do |data| 114 | payload = client.uploads.all( 115 | { 116 | "filter[ids]" => data[:ids].join(","), 117 | }, 118 | deserialize_response: false, 119 | all_pages: true, 120 | ) 121 | 122 | @entities_repo.upsert_entities(payload) 123 | end 124 | end 125 | 126 | def bind_on_upload_destroy(&block) 127 | bind_on("upload:destroy", block) do |data| 128 | @entities_repo.destroy_entities("upload", data[:ids]) 129 | end 130 | end 131 | 132 | def bind_on_item_type_upsert(&block) 133 | bind_on("item_type:upsert", block) do |data| 134 | data[:ids].each do |id| 135 | payload = client.item_types.find(id, {}, deserialize_response: false) 136 | @entities_repo.upsert_entities(payload) 137 | 138 | payload = client.items.all( 139 | { "filter[type]" => id }, 140 | deserialize_response: false, 141 | all_pages: true, 142 | ) 143 | 144 | @entities_repo.upsert_entities(payload) 145 | end 146 | end 147 | end 148 | 149 | def bind_on_item_type_destroy(&block) 150 | bind_on("item_type:destroy", block) do |data| 151 | data[:ids].each do |id| 152 | @entities_repo.destroy_item_type(id) 153 | end 154 | end 155 | end 156 | 157 | def bind_on(event_name, user_block, &block) 158 | pusher.bind(event_name) do |data| 159 | parsed_data = JSON.parse(data) 160 | block.call(parsed_data.deep_symbolize_keys) 161 | update_items_repo! 162 | user_block.call 163 | end 164 | end 165 | 166 | def update_items_repo! 167 | @items_repo = ItemsRepo.new(@entities_repo) 168 | end 169 | 170 | def pusher 171 | PusherClient.logger.level = Logger::WARN 172 | 173 | @pusher ||= PusherClient::Socket.new( 174 | PUSHER_API_KEY, 175 | secure: true, 176 | auth_method: method(:pusher_auth_method), 177 | ) 178 | end 179 | 180 | def site 181 | client.get("/site", include: ["item_types", "item_types.fields"]) 182 | end 183 | 184 | def all_items 185 | client.items.all( 186 | { version: item_version }, 187 | deserialize_response: false, 188 | all_pages: true, 189 | ) 190 | end 191 | 192 | def all_uploads 193 | client.uploads.all({}, 194 | deserialize_response: false, 195 | all_pages: true) 196 | end 197 | 198 | def item_version 199 | if preview_mode 200 | "latest" 201 | else 202 | "published" 203 | end 204 | end 205 | 206 | def pusher_auth_method(socket_id, channel) 207 | client.pusher_token(socket_id, channel.name)["auth"] 208 | end 209 | end 210 | end 211 | end 212 | -------------------------------------------------------------------------------- /lib/dato/local/items_repo.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "dato/local/item" 4 | require "dato/local/site" 5 | 6 | module Dato 7 | module Local 8 | class ItemsRepo 9 | attr_reader :entities_repo, :collections_by_type, :item_type_methods 10 | 11 | def initialize(entities_repo) 12 | @entities_repo = entities_repo 13 | @collections_by_type = {} 14 | @items_by_id = {} 15 | @items_by_parent_id = {} 16 | @item_type_methods = {} 17 | 18 | build_cache! 19 | end 20 | 21 | def find(id) 22 | @items_by_id[id.to_s] 23 | end 24 | 25 | def children_of(id) 26 | @items_by_parent_id.fetch(id.to_s, []) 27 | end 28 | 29 | def respond_to_missing?(method, include_private = false) 30 | if collections_by_type.key?(method) 31 | true 32 | else 33 | super 34 | end 35 | end 36 | 37 | def site 38 | Site.new( 39 | entities_repo.find_entities_of_type("site").first, 40 | self, 41 | ) 42 | end 43 | 44 | def available_locales 45 | site.locales.map(&:to_sym) 46 | end 47 | 48 | def item_types 49 | entities_repo.find_entities_of_type("item_type") 50 | end 51 | 52 | def single_instance_item_types 53 | item_types.select(&:singleton) 54 | end 55 | 56 | def collection_item_types 57 | item_types.select do |item_type| 58 | !item_type.singleton && !item_type.modular_block 59 | end 60 | end 61 | 62 | def items_of_type(item_type) 63 | method = item_type_methods[item_type] 64 | 65 | if item_type.singleton 66 | Array(@collections_by_type[method]) 67 | else 68 | @collections_by_type[method] 69 | end 70 | end 71 | 72 | private 73 | 74 | def build_cache! 75 | build_item_type_methods! 76 | build_collections_by_type! 77 | build_singletons_by_type! 78 | end 79 | 80 | def build_item_type_methods! 81 | @item_type_methods = {} 82 | 83 | singleton_keys = single_instance_item_types.map(&:api_key) 84 | collection_keys = collection_item_types.map(&:api_key) 85 | .map(&:pluralize) 86 | 87 | clashing_keys = singleton_keys & collection_keys 88 | 89 | item_types.each do |item_type| 90 | pluralized_api_key = item_type.api_key.pluralize 91 | 92 | method = if item_type.singleton 93 | item_type.api_key 94 | else 95 | pluralized_api_key 96 | end 97 | 98 | if clashing_keys.include?(pluralized_api_key) 99 | suffix = item_type.singleton ? "instance" : "collection" 100 | method = "#{method}_#{suffix}" 101 | end 102 | 103 | @item_type_methods[item_type] = method.to_sym 104 | end 105 | end 106 | 107 | def build_collections_by_type! 108 | item_types.each do |item_type| 109 | method = item_type_methods[item_type] 110 | @collections_by_type[method] = if item_type.singleton 111 | nil 112 | else 113 | ItemCollection.new 114 | end 115 | end 116 | 117 | item_entities.each do |item_entity| 118 | item = Item.new(item_entity, self) 119 | method = item_type_methods[item_entity.item_type] 120 | 121 | @collections_by_type[method].push item unless item_entity.item_type.singleton 122 | 123 | @items_by_id[item.id] = item 124 | 125 | if item_entity.respond_to?(:parent_id) && item_entity.parent_id 126 | @items_by_parent_id[item_entity.parent_id] ||= [] 127 | @items_by_parent_id[item_entity.parent_id] << item 128 | end 129 | end 130 | 131 | item_types.each do |item_type| 132 | method = item_type_methods[item_type] 133 | if !item_type.singleton && item_type.sortable 134 | nil_items, valid_items = @collections_by_type[method].partition do |item| 135 | item.position.nil? 136 | end 137 | @collections_by_type[method] = valid_items.sort_by(&:position) + nil_items 138 | elsif item_type.ordering_field 139 | field = item_type.ordering_field.api_key 140 | nil_items, valid_items = @collections_by_type[method].partition do |item| 141 | item[field].nil? 142 | end 143 | @collections_by_type[method] = valid_items.sort_by { |item| item[field] } + nil_items 144 | @collections_by_type[method].reverse! if item_type.ordering_direction == "desc" 145 | end 146 | end 147 | end 148 | 149 | def build_singletons_by_type! 150 | item_types.each do |item_type| 151 | method = item_type_methods[item_type] 152 | next unless item_type.singleton 153 | 154 | item = (@items_by_id[item_type.singleton_item.id] if item_type.singleton_item) 155 | 156 | @collections_by_type[method] = item 157 | end 158 | end 159 | 160 | def item_entities 161 | entities_repo.find_entities_of_type("item") 162 | end 163 | 164 | def method_missing(method, *arguments, &block) 165 | if collections_by_type.key?(method) && arguments.empty? 166 | collections_by_type[method] 167 | else 168 | super 169 | end 170 | rescue NoMethodError 171 | message = [] 172 | message << "Undefined method `#{method}`" 173 | message << "Available DatoCMS collections/items:" 174 | message += collections_by_type.map do |key, _value| 175 | "* .#{key}" 176 | end 177 | raise NoMethodError, message.join("\n") 178 | end 179 | 180 | class ItemCollection < Array 181 | def each(&block) 182 | if block && block.arity == 2 183 | each_with_object({}) do |item, acc| 184 | acc[item.id] = item 185 | end.each(&block) 186 | else 187 | super(&block) 188 | end 189 | end 190 | 191 | def [](id) 192 | if id.is_a? String 193 | find { |item| item.id == id } 194 | else 195 | super(id) 196 | end 197 | end 198 | 199 | def keys 200 | map(&:id) 201 | end 202 | 203 | def values 204 | to_a 205 | end 206 | end 207 | end 208 | end 209 | end 210 | -------------------------------------------------------------------------------- /spec/dato/local/item_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | module Dato 6 | module Local 7 | RSpec.describe Item do 8 | subject(:item) do 9 | ItemsRepo.new(entities_repo).work_items.first 10 | end 11 | 12 | let(:entity) do 13 | entities_repo.find_entities_of_type("item").first 14 | end 15 | 16 | let(:entities_repo) do 17 | EntitiesRepo.new(entities) 18 | end 19 | 20 | let(:repo) do 21 | ItemsRepo.new(entities_repo) 22 | end 23 | 24 | let(:entities) do 25 | { 26 | data: [ 27 | { 28 | id: "item", 29 | type: "item", 30 | attributes: item_attributes, 31 | meta: { 32 | updated_at: "2010-01-01T00:00", 33 | }, 34 | relationships: { 35 | item_type: { 36 | data: { 37 | id: "item-type", 38 | type: "item_type", 39 | }, 40 | }, 41 | }, 42 | }, 43 | { 44 | id: "item-type", 45 | type: "item_type", 46 | attributes: { 47 | singleton: is_singleton, 48 | modular_block: false, 49 | sortable: true, 50 | api_key: "work_item", 51 | }, 52 | relationships: { 53 | fields: { 54 | data: [ 55 | { id: "title", type: "field" }, 56 | { id: "body", type: "field" }, 57 | ], 58 | }, 59 | }, 60 | }, 61 | { 62 | id: "title", 63 | type: "field", 64 | attributes: { 65 | position: 1, 66 | api_key: title_api_key, 67 | localized: title_localized, 68 | field_type: title_field_type, 69 | appearance: { 70 | addons: [], 71 | editor: "single_line", 72 | parameters: { heading: true }, 73 | }, 74 | }, 75 | }, 76 | { 77 | id: "body", 78 | type: "field", 79 | attributes: { 80 | position: 2, 81 | api_key: "body", 82 | localized: false, 83 | field_type: "text", 84 | appearance: { 85 | addons: [], 86 | editor: "markdown", 87 | parameters: { toolbar: ["bold"] }, 88 | }, 89 | }, 90 | }, 91 | ], 92 | } 93 | end 94 | 95 | let(:is_singleton) { false } 96 | let(:title_localized) { false } 97 | let(:title_field_type) { "string" } 98 | let(:title_api_key) { "title" } 99 | 100 | let(:item_attributes) do 101 | { 102 | title_api_key.to_sym => "My titlè with àccents", 103 | body: "Hi there", 104 | position: 2, 105 | } 106 | end 107 | 108 | describe "#attributes" do 109 | it "returns an hash of the field values" do 110 | expected_attributes = { 111 | "title" => "My titlè with àccents", 112 | "body" => "Hi there", 113 | } 114 | expect(item.attributes).to eq expected_attributes 115 | end 116 | end 117 | 118 | describe "position" do 119 | it "returns the entity position field" do 120 | expect(item.position).to eq 2 121 | end 122 | end 123 | 124 | describe "updated_at" do 125 | it "returns the entity updated_at field" do 126 | expect(item.updated_at).to be_a Time 127 | end 128 | end 129 | 130 | describe "dynamic methods" do 131 | context "existing field" do 132 | it "returns the field value" do 133 | expect(item.respond_to?(:body)).to be_truthy 134 | expect(item.body).to eq "Hi there" 135 | expect(item[:body]).to eq "Hi there" 136 | expect(item["body"]).to eq "Hi there" 137 | end 138 | 139 | context "localized field" do 140 | let(:item_attributes) do 141 | super().merge(title: { it: "Foo", en: "Bar" }) 142 | end 143 | 144 | let(:title_localized) { true } 145 | 146 | it "returns the value for the current locale" do 147 | I18n.with_locale(:it) do 148 | expect(item.title).to eq "Foo" 149 | end 150 | end 151 | 152 | context "non existing value" do 153 | it "raises nil" do 154 | I18n.with_locale(:ru) do 155 | expect(item.title).to eq nil 156 | end 157 | end 158 | end 159 | 160 | context "fallbacks" do 161 | let(:item_attributes) do 162 | super().merge(title: { ru: nil, "es-ES": "Bar" }) 163 | end 164 | 165 | it "uses them" do 166 | I18n.with_locale(:ru) do 167 | expect(item.title).to eq "Bar" 168 | end 169 | end 170 | end 171 | end 172 | end 173 | 174 | context "non existing field" do 175 | it "raises NoMethodError" do 176 | expect(item.respond_to?(:qux)).to be_falsy 177 | expect { item.qux }.to raise_error NoMethodError 178 | end 179 | end 180 | 181 | context "non existing field type" do 182 | let(:title_field_type) { "rotfl" } 183 | 184 | it "returns the raw item value" do 185 | expect(item.title).to eq "My titlè with àccents" 186 | end 187 | end 188 | 189 | context "field with api_key = meta" do 190 | let(:title_api_key) { "meta" } 191 | 192 | it ".meta returns the meta info" do 193 | expect(item.meta).to be_a JsonApiMeta 194 | expect(item["meta"]).to eq "My titlè with àccents" 195 | expect(item[:meta]).to eq "My titlè with àccents" 196 | end 197 | end 198 | end 199 | 200 | context "meta" do 201 | it "returns raw info" do 202 | expect(item.meta.updated_at).to eq "2010-01-01T00:00" 203 | end 204 | end 205 | 206 | context "equality" do 207 | subject(:same_item) { described_class.new(entity, repo) } 208 | 209 | subject(:another_item) { described_class.new(another_entity, repo) } 210 | let(:another_entity) do 211 | double( 212 | "Dato::Local::JsonApiEntity(Item)", 213 | id: "15", 214 | ) 215 | end 216 | 217 | it "two items are equal if their id is the same" do 218 | expect(item).to eq same_item 219 | end 220 | 221 | it "else they're not" do 222 | expect(item).not_to eq another_item 223 | expect(item).not_to eq "foobar" 224 | end 225 | end 226 | end 227 | end 228 | end 229 | -------------------------------------------------------------------------------- /spec/support/shared_contexts/new_site.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.shared_context "with a new site" do 4 | def b(type, *other) 5 | node = { 6 | "type" => type, 7 | } 8 | 9 | param = other.shift 10 | 11 | if param.is_a?(Hash) 12 | node.merge!(param) 13 | param = other.shift 14 | end 15 | 16 | node["children"] = param if param.is_a?(Array) 17 | 18 | node["value"] = param if param.is_a?(String) 19 | 20 | node.stringify_keys 21 | end 22 | 23 | let(:account_client) do 24 | generate_account_client! 25 | end 26 | 27 | let(:site) do 28 | account_client.sites.create( 29 | name: "Integration new test site", 30 | ) 31 | end 32 | 33 | let(:client) do 34 | Dato::Site::Client.new( 35 | site[:readwrite_token], 36 | base_url: ENV.fetch("SITE_API_BASE_URL"), 37 | ) 38 | end 39 | 40 | let(:item_type) do 41 | client.item_types.create( 42 | name: "Article", 43 | singleton: false, 44 | modular_block: false, 45 | sortable: false, 46 | tree: false, 47 | draft_mode_active: false, 48 | api_key: "article", 49 | ordering_direction: nil, 50 | ordering_field: nil, 51 | all_locales_required: true, 52 | title_field: nil, 53 | ) 54 | end 55 | 56 | let(:author_type) do 57 | client.item_types.create( 58 | name: "Author", 59 | singleton: false, 60 | modular_block: false, 61 | sortable: false, 62 | tree: false, 63 | draft_mode_active: false, 64 | api_key: "author", 65 | ordering_direction: nil, 66 | ordering_field: nil, 67 | all_locales_required: true, 68 | title_field: nil, 69 | ) 70 | end 71 | 72 | let(:author_field) do 73 | client.fields.create( 74 | author_type[:id], 75 | api_key: "name", 76 | field_type: "string", 77 | label: "Name", 78 | localized: false, 79 | hint: "", 80 | validators: { required: {} }, 81 | ) 82 | end 83 | 84 | let(:block_type) do 85 | client.item_types.create( 86 | name: "Block", 87 | singleton: false, 88 | modular_block: true, 89 | sortable: false, 90 | tree: false, 91 | draft_mode_active: false, 92 | api_key: "block", 93 | ordering_direction: nil, 94 | ordering_field: nil, 95 | all_locales_required: true, 96 | title_field: nil, 97 | ) 98 | end 99 | 100 | let(:block_field) do 101 | client.fields.create( 102 | block_type[:id], 103 | api_key: "title", 104 | field_type: "string", 105 | label: "Title", 106 | localized: false, 107 | hint: "", 108 | validators: { required: {} }, 109 | ) 110 | end 111 | 112 | let(:structured_text_field) do 113 | client.fields.create( 114 | item_type[:id], 115 | api_key: "content", 116 | field_type: "structured_text", 117 | label: "Content", 118 | localized: false, 119 | hint: "", 120 | validators: { 121 | structured_text_blocks: { item_types: [block_type[:id]] }, 122 | structured_text_links: { item_types: [author_type[:id]] }, 123 | }, 124 | ) 125 | end 126 | 127 | let(:text_field) do 128 | client.fields.create( 129 | item_type[:id], 130 | api_key: "title", 131 | field_type: "string", 132 | label: "Title", 133 | localized: true, 134 | hint: "", 135 | validators: { required: {} }, 136 | ) 137 | end 138 | 139 | let(:slug_field) do 140 | client.fields.create( 141 | item_type[:id], 142 | api_key: "slug", 143 | field_type: "slug", 144 | label: "Slug", 145 | localized: false, 146 | hint: "", 147 | validators: { 148 | required: {}, 149 | slug_title_field: { 150 | title_field_id: text_field[:id].to_s, 151 | }, 152 | }, 153 | ) 154 | end 155 | 156 | let(:image_field) do 157 | client.fields.create( 158 | item_type[:id], 159 | api_key: "image", 160 | field_type: "file", 161 | label: "Image", 162 | localized: false, 163 | hint: "", 164 | validators: { 165 | required: {}, 166 | extension: { 167 | predefined_list: "image", 168 | }, 169 | }, 170 | ) 171 | end 172 | 173 | let(:file_field) do 174 | client.fields.create( 175 | item_type[:id], 176 | api_key: "file", 177 | field_type: "file", 178 | label: "File", 179 | localized: false, 180 | hint: "", 181 | validators: { required: {} }, 182 | ) 183 | end 184 | 185 | let(:image_id) { client.upload_image("https://www.datocms-assets.com/205/1549027974-logo.png")[:upload_id] } 186 | let(:file_id) { client.upload_file("./spec/fixtures/file.txt")[:upload_id] } 187 | 188 | let(:author) do 189 | client.items.create( 190 | item_type: author_type[:id], 191 | author_field[:api_key] => "Mark Smith", 192 | ) 193 | end 194 | 195 | let(:item) do 196 | client.items.create( 197 | item_type: item_type[:id], 198 | text_field[:api_key] => { 199 | en: "First post", 200 | it: "Primo post", 201 | }, 202 | slug_field[:api_key] => "first-post", 203 | image_field[:api_key] => { 204 | upload_id: image_id, 205 | alt: "My first post", 206 | title: "First post", 207 | custom_data: {}, 208 | focal_point: { 209 | x: 0.1, 210 | y: 0.1, 211 | }, 212 | }, 213 | structured_text_field[:api_key] => { 214 | schema: "dast", 215 | document: b("root", [ 216 | b("heading", { level: 1 }, [b("span", "This is the title!")]), 217 | b("paragraph", [ 218 | b("span", "And "), 219 | b("span", { marks: ["strong"] }, "this"), 220 | b("span", " is an "), 221 | b("itemLink", { item: author[:id] }, [b("span", "author")]), 222 | ]), 223 | b("paragraph", [b("inlineItem", { item: author[:id] })]), 224 | b("block", { 225 | item: Dato::Utils::BuildModularBlock.build({ 226 | item_type: block_type[:id], 227 | block_field[:api_key] => "Foo", 228 | }), 229 | }), 230 | ]), 231 | }, 232 | file_field[:api_key] => { 233 | upload_id: file_id, 234 | alt: "My first file", 235 | title: "My first file", 236 | custom_data: {}, 237 | }, 238 | ) 239 | end 240 | 241 | before do 242 | site 243 | 244 | client.site.update( 245 | client.site.find.merge( 246 | locales: %w[en it], 247 | theme: { 248 | logo: client.upload_image("./spec/fixtures/dato-logo.jpg")[:upload_id], 249 | primary_color: { 250 | red: 63, 251 | green: 63, 252 | blue: 63, 253 | alpha: 63, 254 | }, 255 | dark_color: { 256 | red: 0, 257 | green: 0, 258 | blue: 0, 259 | alpha: 0, 260 | }, 261 | light_color: { 262 | red: 127, 263 | green: 127, 264 | blue: 127, 265 | alpha: 127, 266 | }, 267 | accent_color: { 268 | red: 255, 269 | green: 255, 270 | blue: 255, 271 | alpha: 255, 272 | }, 273 | }, 274 | ), 275 | ) 276 | 277 | client.items.publish(item[:id]) 278 | end 279 | end 280 | -------------------------------------------------------------------------------- /lib/dato/local/field_type/file.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "imgix" 4 | 5 | module Dato 6 | module Local 7 | module FieldType 8 | class File 9 | def self.parse(value, repo) 10 | if value 11 | v = value.with_indifferent_access 12 | 13 | upload = repo.entities_repo.find_entity("upload", v[:upload_id]) 14 | 15 | if upload 16 | new( 17 | upload, 18 | v[:alt], 19 | v[:title], 20 | v[:custom_data], 21 | v[:focal_point], 22 | repo.site.entity.imgix_host, 23 | ) 24 | end 25 | end 26 | end 27 | 28 | def initialize( 29 | upload, 30 | alt, 31 | title, 32 | custom_data, 33 | focal_point, 34 | imgix_host 35 | ) 36 | @upload = upload 37 | @alt = alt 38 | @title = title 39 | @custom_data = custom_data 40 | @focal_point = focal_point 41 | @imgix_host = imgix_host 42 | end 43 | 44 | def id 45 | @upload.id 46 | end 47 | 48 | def path 49 | @upload.path 50 | end 51 | 52 | def format 53 | @upload.format 54 | end 55 | 56 | def size 57 | @upload.size 58 | end 59 | 60 | def width 61 | @upload.width 62 | end 63 | 64 | def height 65 | @upload.height 66 | end 67 | 68 | def author 69 | @upload.author 70 | end 71 | 72 | def notes 73 | @upload.notes 74 | end 75 | 76 | def copyright 77 | @upload.copyright 78 | end 79 | 80 | def filename 81 | @upload.filename 82 | end 83 | 84 | def basename 85 | @upload.basename 86 | end 87 | 88 | def alt 89 | default_metadata = @upload.default_field_metadata.deep_stringify_keys 90 | .fetch(I18n.locale.to_s, {}) 91 | @alt || default_metadata["alt"] 92 | end 93 | 94 | def title 95 | default_metadata = @upload.default_field_metadata.deep_stringify_keys 96 | .fetch(I18n.locale.to_s, {}) 97 | @title || default_metadata["title"] 98 | end 99 | 100 | def custom_data 101 | default_metadata = @upload.default_field_metadata.deep_stringify_keys 102 | .fetch(I18n.locale.to_s, {}) 103 | @custom_data.merge(default_metadata.fetch("custom_data", {})) 104 | end 105 | 106 | def focal_point 107 | default_metadata = @upload.default_field_metadata.deep_stringify_keys 108 | .fetch(I18n.locale.to_s, {}) 109 | @focal_point || default_metadata["focal_point"] 110 | end 111 | 112 | def tags 113 | @upload.tags 114 | end 115 | 116 | def smart_tags 117 | @upload.smart_tags 118 | end 119 | 120 | def is_image 121 | @upload.is_image 122 | end 123 | 124 | def exif_info 125 | @upload.exif_info 126 | end 127 | 128 | def mime_type 129 | @upload.mime_type 130 | end 131 | 132 | def colors 133 | @upload.colors.map { |color| Color.parse(color, nil) } 134 | end 135 | 136 | def blurhash 137 | @upload.blurhash 138 | end 139 | 140 | class VideoAttributes 141 | def initialize(upload) 142 | @upload = upload 143 | end 144 | 145 | def mux_playback_id 146 | @upload.mux_playback_id 147 | end 148 | 149 | def frame_rate 150 | @upload.frame_rate 151 | end 152 | 153 | def duration 154 | @upload.duration 155 | end 156 | 157 | def streaming_url 158 | "https://stream.mux.com/#{@upload.mux_playback_id}.m3u8" 159 | end 160 | 161 | def thumbnail_url(format = :jpg) 162 | if format == :gif 163 | "https://image.mux.com/#{@upload.mux_playback_id}/animated.gif" 164 | else 165 | "https://image.mux.com/#{@upload.mux_playback_id}/thumbnail.#{format}" 166 | end 167 | end 168 | 169 | def mp4_url(options = nil) 170 | @upload.mux_mp4_highest_res or 171 | return nil 172 | 173 | if options && options[:exact_res] 174 | if options[:exact_res] == :low 175 | raw_mp4_url("low") 176 | elsif options[:exact_res] == :medium 177 | raw_mp4_url("medium") if %w[medium high].include?(@upload.mux_mp4_highest_res) 178 | elsif @upload.mux_mp4_highest_res == :high 179 | raw_mp4_url("high") 180 | end 181 | elsif options && options[:res] == :low 182 | raw_mp4_url("low") 183 | elsif options && options[:res] == :medium 184 | if %w[low medium].include?(@upload.mux_mp4_highest_res) 185 | raw_mp4_url(@upload.mux_mp4_highest_res) 186 | else 187 | raw_mp4_url("medium") 188 | end 189 | else 190 | raw_mp4_url(@upload.mux_mp4_highest_res) 191 | end 192 | end 193 | 194 | def to_hash 195 | { 196 | mux_playback_id: mux_playback_id, 197 | frame_rate: frame_rate, 198 | duration: duration, 199 | streaming_url: streaming_url, 200 | thumbnail_url: thumbnail_url, 201 | mp4_url: mp4_url, 202 | } 203 | end 204 | 205 | private 206 | 207 | def raw_mp4_url(res) 208 | "https://stream.mux.com/#{@upload.mux_playback_id}/#{res}.mp4" 209 | end 210 | end 211 | 212 | def video 213 | VideoAttributes.new(@upload) if @upload.mux_playback_id 214 | end 215 | 216 | def file 217 | Imgix::Client.new( 218 | domain: @imgix_host, 219 | secure: true, 220 | include_library_param: false, 221 | ).path(path) 222 | end 223 | 224 | def url(query = {}) 225 | query.deep_stringify_keys! 226 | 227 | if focal_point && 228 | query["fit"] == "crop" && 229 | (query["h"] || query["height"]) && 230 | (query["w"] || query["width"]) && 231 | [nil, "focalpoint"].include?(query["crop"]) && 232 | query["fp-x"].nil? && 233 | query["fp-y"].nil? 234 | 235 | query.merge!( 236 | "crop" => "focalpoint", 237 | "fp-x" => focal_point[:x], 238 | "fp-y" => focal_point[:y], 239 | ) 240 | end 241 | 242 | file.to_url(query) 243 | end 244 | 245 | def lqip_data_url(opts = {}) 246 | @imgix_host != "www.datocms-assets.com" and 247 | raise "#lqip_data_url can only be used with www.datocms-assets.com domain" 248 | 249 | response = Faraday.get(file.to_url(opts.merge(lqip: "blurhash"))) 250 | 251 | "data:image/jpeg;base64,#{Base64.strict_encode64(response.body)}" if response.status == 200 252 | end 253 | 254 | def to_hash(*_args) 255 | { 256 | id: id, 257 | format: format, 258 | size: size, 259 | width: width, 260 | height: height, 261 | alt: alt, 262 | title: title, 263 | custom_data: custom_data, 264 | focal_point: focal_point, 265 | url: url, 266 | copyright: copyright, 267 | tags: tags, 268 | smart_tags: smart_tags, 269 | filename: filename, 270 | basename: basename, 271 | is_image: is_image, 272 | exif_info: exif_info, 273 | mime_type: mime_type, 274 | colors: colors.map(&:to_hash), 275 | blurhash: blurhash, 276 | video: video && video.to_hash, 277 | } 278 | end 279 | end 280 | end 281 | end 282 | end 283 | -------------------------------------------------------------------------------- /spec/support/shared_contexts/meta_tags.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.shared_context "items repo" do 4 | let(:item) { nil } 5 | let(:site) { items_repo.site } 6 | let(:items_repo) { Dato::Local::ItemsRepo.new(entities_repo) } 7 | let(:entities_repo) { Dato::Local::EntitiesRepo.new(payload) } 8 | let(:payload) do 9 | { 10 | data: [ 11 | { 12 | id: "24038", 13 | type: "item", 14 | attributes: { 15 | is_valid: true, 16 | title: item_title, 17 | another_string: "Foo bar", 18 | seo_settings: seo, 19 | image: ( 20 | if item_image 21 | { 22 | upload_id: item_image, 23 | alt: nil, 24 | title: nil, 25 | custom_data: {}, 26 | } 27 | end 28 | ), 29 | }, 30 | meta: { 31 | updated_at: "2016-12-07T09:14:22.046Z", 32 | }, 33 | relationships: { 34 | item_type: { 35 | data: { 36 | id: "3781", 37 | type: "item_type", 38 | }, 39 | }, 40 | }, 41 | }, 42 | { 43 | id: "666", 44 | type: "upload", 45 | attributes: { 46 | path: "/seo.png", 47 | width: 500, 48 | height: 500, 49 | format: "png", 50 | size: 572_451, 51 | default_field_metadata: { 52 | en: { 53 | alt: "an alt", 54 | title: "a title", 55 | custom_data: {}, 56 | }, 57 | }, 58 | }, 59 | }, 60 | { 61 | id: "999", 62 | type: "upload", 63 | attributes: { 64 | path: "/fallback_seo.png", 65 | width: 500, 66 | height: 500, 67 | format: "png", 68 | size: 543_210, 69 | default_field_metadata: { 70 | en: { 71 | alt: "another alt", 72 | title: "another title", 73 | custom_data: {}, 74 | }, 75 | }, 76 | }, 77 | }, 78 | { 79 | id: "111", 80 | type: "upload", 81 | attributes: { 82 | path: "/simple_image.png", 83 | width: 500, 84 | height: 500, 85 | format: "png", 86 | size: 543_210, 87 | default_field_metadata: { 88 | en: { 89 | alt: "image alt", 90 | title: "image title", 91 | custom_data: {}, 92 | }, 93 | }, 94 | }, 95 | }, 96 | { 97 | id: "681", 98 | type: "site", 99 | attributes: { 100 | name: "XXX", 101 | locales: ["en"], 102 | theme_hue: 190, 103 | domain: nil, 104 | internal_domain: "wispy-sun-3056.admin.datocms.com", 105 | global_seo: global_seo, 106 | favicon: favicon, 107 | no_index: no_index, 108 | ssg: nil, 109 | imgix_host: "www.datocms-assets.com", 110 | }, 111 | relationships: { 112 | menu_items: { 113 | data: [ 114 | { 115 | id: "4212", 116 | type: "menu_item", 117 | }, 118 | ], 119 | }, 120 | item_types: { 121 | data: [ 122 | { 123 | id: "3781", 124 | type: "item_type", 125 | }, 126 | ], 127 | }, 128 | }, 129 | }, 130 | { 131 | id: "3781", 132 | type: "item_type", 133 | attributes: { 134 | name: "Article", 135 | singleton: false, 136 | modular_block: false, 137 | draft_mode_active: false, 138 | sortable: false, 139 | api_key: "article", 140 | ordering_direction: nil, 141 | }, 142 | relationships: { 143 | fields: { 144 | data: [ 145 | { 146 | id: "15088", 147 | type: "field", 148 | }, 149 | { 150 | id: "15085", 151 | type: "field", 152 | }, 153 | { 154 | id: "15086", 155 | type: "field", 156 | }, 157 | { 158 | id: "15087", 159 | type: "field", 160 | }, 161 | ], 162 | }, 163 | singleton_item: { 164 | data: nil, 165 | }, 166 | ordering_field: { 167 | data: nil, 168 | }, 169 | title_field: { 170 | data: { 171 | id: "15085", 172 | type: "field", 173 | }, 174 | }, 175 | }, 176 | }, 177 | { 178 | id: "15088", 179 | type: "field", 180 | attributes: { 181 | label: "Image", 182 | field_type: "file", 183 | api_key: "image", 184 | hint: nil, 185 | localized: false, 186 | validators: {}, 187 | position: 1, 188 | appearance: { 189 | editor: "file", 190 | parameters: {}, 191 | }, 192 | }, 193 | relationships: { 194 | item_type: { 195 | data: { 196 | id: "3781", 197 | type: "item_type", 198 | }, 199 | }, 200 | }, 201 | }, 202 | { 203 | id: "15085", 204 | type: "field", 205 | attributes: { 206 | label: "Title", 207 | field_type: "string", 208 | api_key: "title", 209 | hint: nil, 210 | localized: false, 211 | validators: { 212 | required: {}, 213 | }, 214 | position: 2, 215 | appearance: { 216 | editor: "single_line", 217 | parameters: { heading: true }, 218 | }, 219 | }, 220 | relationships: { 221 | item_type: { 222 | data: { 223 | id: "3781", 224 | type: "item_type", 225 | }, 226 | }, 227 | }, 228 | }, 229 | { 230 | id: "15086", 231 | type: "field", 232 | attributes: { 233 | label: "Another string", 234 | field_type: "string", 235 | api_key: "another_string", 236 | hint: nil, 237 | localized: false, 238 | validators: {}, 239 | position: 3, 240 | appearance: { 241 | editor: "single_line", 242 | parameters: { heading: false }, 243 | }, 244 | }, 245 | relationships: { 246 | item_type: { 247 | data: { 248 | id: "3781", 249 | type: "item_type", 250 | }, 251 | }, 252 | }, 253 | }, 254 | { 255 | id: "15087", 256 | type: "field", 257 | attributes: { 258 | label: "SEO settings", 259 | field_type: "seo", 260 | api_key: "seo_settings", 261 | hint: nil, 262 | localized: false, 263 | validators: {}, 264 | position: 4, 265 | appearance: { 266 | editor: "seo", 267 | parameters: {}, 268 | }, 269 | }, 270 | relationships: { 271 | item_type: { 272 | data: { 273 | id: "3781", 274 | type: "item_type", 275 | }, 276 | }, 277 | }, 278 | }, 279 | ], 280 | } 281 | end 282 | let(:item_title) { nil } 283 | let(:item_image) { nil } 284 | let(:favicon) { nil } 285 | let(:global_seo) { nil } 286 | let(:seo) { nil } 287 | let(:no_index) { false } 288 | let(:title_suffix) { nil } 289 | end 290 | --------------------------------------------------------------------------------