spooky-engine 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 919bd501aa2120561eaf4ea2e402bdf22bc6ff65b07824cd8bd415851637e86e
4
+ data.tar.gz: e5e48a84fb70918437b518d5212884f367e642f5788efa5fb3c44aab12563361
5
+ SHA512:
6
+ metadata.gz: 292c2fb2e7825262dd9128c4e154e2e0382bd6b0e9270d3341de39024cb08194a78e731a57d45ad676412904c182cd0a3985dae8f9dbe319fa515423235cf13d
7
+ data.tar.gz: c5551b0b3c4a29b4c1efe490acffe60dfa40612ac50ca50b8ac580781574ba1bcd8a65c414e4b40ec669bc50fa99de8a5c12d62535e4f974052537ee7b31a83d
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright Collective Idea, Inc.
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,95 @@
1
+ [![Gem Version](https://img.shields.io/gem/v/spooky-engine.svg)](https://rubygems.org/gems/spooky-engine)
2
+ [![CI](https://github.com/collectiveidea/spooky-engine/actions/workflows/ci.yml/badge.svg)](https://github.com/collectiveidea/spooky-engine/actions/workflows/ci.yml)
3
+ [![Ruby Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://github.com/testdouble/standard)
4
+
5
+
6
+ # SpookyEngine
7
+ Quickly drop a [Ghost](https://ghost.org) blog into a Rails app. Uses the `Spooky` gem (hence the name) to connect to Ghost's API.
8
+
9
+ Have access to your blog at `/blog` or wherever you want it. No assumptions. It can use use your layouts and styles with minimal configuration. All views can be quickly overridden by providing your own templates.
10
+
11
+ ## Installation
12
+ Add this line to your application's Gemfile:
13
+
14
+ ```ruby
15
+ # Need our fork of spooky for now.
16
+ gem "spooky", git: "https://github.com/collectiveidea/spooky", branch: "active_model"
17
+ gem "spooky-engine"
18
+ ```
19
+
20
+ ## Usage
21
+
22
+ 1. Add this to your `routes.rb` and change the `at:` path as you desire:
23
+
24
+ ```ruby
25
+ mount Spooky::Engine, at: "/blog", as: "blog"
26
+ ```
27
+
28
+ 2. Set ENV vars for Spooky to connect to your Ghost instance:
29
+ ```
30
+ GHOST_API_URL="https://blog.yourdomain.com"
31
+ GHOST_CONTENT_API_KEY="abc123"
32
+ ```
33
+
34
+ 3. Optionally configure (mainly for the Atom feed right now). Add this in an initializer:
35
+
36
+ ```ruby
37
+ Rails.application.config.spooky_engine.config do |config|
38
+ config.title = "My Amazing Blog"
39
+ config.subtitle = "This is some cool stuff you should read."
40
+ config.rights = "© 2025 Me. All rights reserved."
41
+ config.icon = "icon.png" # gets passed to asset_url()
42
+ config.logo = "logo.png" # gets passed to asset_url()
43
+ end
44
+ ```
45
+
46
+ ## Customizing
47
+
48
+ The provided views are intentionally simplistic. You can style them however you want, or replace all or some of them entirely.
49
+
50
+ Put your own views in `app/views/spooky/*` and they'll take priority. To link to any blog-related resources, make sure you prefix with `spooky_engine.`. Example: `spooky_engine.page_path("about")`
51
+
52
+ ### Customize layout
53
+
54
+ The layout is the one thing you'll probalby want to change.
55
+
56
+ Either way, make sure to add the link to the Atom feed in your `<head>`
57
+
58
+ ```ruby
59
+ <%= auto_discovery_link_tag :atom, spooky_engine.atom_path %>
60
+ ```
61
+
62
+ #### Option 1: New Layout
63
+
64
+ Add a new layout in `app/views/layouts/spooky/application.html.erb` and it will work.
65
+
66
+ #### Option 2: Use an existing layout.
67
+
68
+ Add this to an initializer:
69
+
70
+ ```ruby
71
+ Rails.application.config.to_prepare do
72
+ # Use your application layout, or any you prefer.
73
+ Spooky::ApplicationController.layout "application"
74
+ end
75
+ ```
76
+
77
+ ## Routes
78
+
79
+ Routes exist for Posts, Pages, Authors and Tags. We use `slug` instead everywhere, so you get nice URLs, like `/pages/about`.
80
+
81
+ ## Webhooks
82
+
83
+ Receive Ghost's webhooks easily. Point them at `/blog/webhooks` (assuming you mounted the engine at `/blog`) and then you can subscribe via ActiveSupport::Notifications:
84
+
85
+ ```ruby
86
+ ActiveSupport::Notifications.subscribe('webhook.spooky') do |event|
87
+ event.payload # webhook body
88
+ end
89
+ ```
90
+
91
+ ## Version support
92
+ This was built expecting Rails 8+. It probalby works with older versions but hasn't been tested. Fork it, try it, and PRs welcome!
93
+
94
+ ## License
95
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ require "bundler/setup"
2
+
3
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
+
6
+ load "rails/tasks/statistics.rake"
7
+
8
+ require "bundler/gem_tasks"
@@ -0,0 +1,15 @@
1
+ /*
2
+ * This is a manifest file that'll be compiled into application.css, which will include all the files
3
+ * listed below.
4
+ *
5
+ * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
6
+ * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
7
+ *
8
+ * You're free to add application-wide styles to this file and they'll appear at the bottom of the
9
+ * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
10
+ * files in this directory. Styles in this file should be added after the last require_* statement.
11
+ * It is generally better to create a new file per style scope.
12
+ *
13
+ *= require_tree .
14
+ *= require_self
15
+ */
@@ -0,0 +1,13 @@
1
+ module Spooky
2
+ class ApplicationController < ActionController::Base
3
+ private
4
+
5
+ def ghost
6
+ @ghost ||= Spooky::Client.new
7
+ end
8
+
9
+ def render_404
10
+ render file: Rails.public_path.join("404.html"), layout: false
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,19 @@
1
+ module Spooky
2
+ class AuthorsController < ApplicationController
3
+ def index
4
+ @authors, @pagination = ghost.authors(
5
+ include: "count.posts",
6
+ filter: "visibility:public",
7
+ page: params[:page].presence,
8
+ limit: params[:limit].presence
9
+ )
10
+
11
+ render_404 unless @authors
12
+ end
13
+
14
+ def show
15
+ @author = ghost.author_by(slug: params[:id])
16
+ render_404 unless @author
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,19 @@
1
+ module Spooky
2
+ class PagesController < ApplicationController
3
+ def index
4
+ @pages, @pagination = ghost.pages(
5
+ include: "authors,tags",
6
+ filter: "visibility:public",
7
+ page: params[:page].presence,
8
+ limit: params[:limit].presence
9
+ )
10
+
11
+ render_404 unless @pages
12
+ end
13
+
14
+ def show
15
+ @page = ghost.page_by(slug: params[:id])
16
+ render_404 unless @page
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,24 @@
1
+ module Spooky
2
+ class PostsController < ApplicationController
3
+ def index
4
+ @posts, @pagination = ghost.posts(
5
+ include: "authors,tags",
6
+ filter: "visibility:public",
7
+ page: params[:page].presence,
8
+ limit: params[:limit].presence
9
+ )
10
+
11
+ render_404 unless @posts
12
+
13
+ respond_to do |format|
14
+ format.html
15
+ format.atom
16
+ end
17
+ end
18
+
19
+ def show
20
+ @post = ghost.post_by(slug: params[:id])
21
+ render_404 unless @post
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,19 @@
1
+ module Spooky
2
+ class TagsController < ApplicationController
3
+ def index
4
+ @tags, @pagination = ghost.tags(
5
+ include: "count.posts",
6
+ filter: "visibility:public",
7
+ page: params[:page].presence,
8
+ limit: params[:limit].presence
9
+ )
10
+
11
+ render_404 unless @tags
12
+ end
13
+
14
+ def show
15
+ @tag = ghost.tag_by(slug: params[:id])
16
+ render_404 unless @tag
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,24 @@
1
+ module Spooky
2
+ class WebhooksController < ActionController::API
3
+ before_action :verify_signature
4
+
5
+ def create
6
+ ActiveSupport::Notifications.instrument("webhook.spooky", payload: request.raw_post) {}
7
+ head :ok
8
+ end
9
+
10
+ private
11
+
12
+ def verify_signature
13
+ raise "Missing GHOST_WEBHOOK_SECRET" unless ENV["GHOST_WEBHOOK_SECRET"].present?
14
+ signature = request.headers["x-ghost-signature"].match(/sha256=(?<sha>[a-z0-9]+), t=(?<t>\d+)/)
15
+
16
+ expected_signature = OpenSSL::HMAC.hexdigest(
17
+ "sha256",
18
+ ENV["GHOST_WEBHOOK_SECRET"],
19
+ request.raw_post.to_s + signature["t"] # Append timestamp from the header to the body.
20
+ )
21
+ raise "Invalid Signature" unless ActiveSupport::SecurityUtils.secure_compare(expected_signature, signature["sha"])
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,8 @@
1
+ module SpookyEngine
2
+ module ApplicationHelper
3
+ # Simplify path generation
4
+ def spooky_pagination_path(options)
5
+ spooky_engine.url_for(params.permit(:page, :limit).merge(options))
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,4 @@
1
+ module SpookyEngine
2
+ class ApplicationJob < ActiveJob::Base
3
+ end
4
+ end
@@ -0,0 +1,18 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Quick blog</title>
5
+ <%= csrf_meta_tags %>
6
+ <%= csp_meta_tag %>
7
+
8
+ <%= yield :head %>
9
+
10
+ <%= stylesheet_link_tag "spooky/application", media: "all" %>
11
+ <%= auto_discovery_link_tag :atom, spooky_engine.atom_path %>
12
+ </head>
13
+ <body>
14
+
15
+ <%= yield %>
16
+
17
+ </body>
18
+ </html>
@@ -0,0 +1,9 @@
1
+ <h1>Authors</h1>
2
+
3
+ <ul>
4
+ <% @authors.each do |author| %>
5
+ <li><%= link_to author.name, spooky_engine.author_path(author) %></li>
6
+ <% end %>
7
+ </ul>
8
+
9
+ <%= render "spooky/shared/pagination" %>
@@ -0,0 +1,8 @@
1
+ <div>
2
+ <%= image_tag @author.cover_image if @author.cover_image.present? %>
3
+ <%= image_tag @author.profile_image if @author.profile_image.present? %>
4
+ <h1><%= @author.name %></h1>
5
+ <p><%= @author.bio %></p>
6
+ <p><%= @author.website %></p>
7
+ <p><%= @author.location %></p>
8
+ </div>
@@ -0,0 +1,9 @@
1
+ <h1>Pages</h1>
2
+
3
+ <ul>
4
+ <% @pages.each do |page| %>
5
+ <li><%= link_to page.title, spooky_engine.page_path(page.slug) %></li>
6
+ <% end %>
7
+ </ul>
8
+
9
+ <%= render "spooky/shared/pagination" %>
@@ -0,0 +1,10 @@
1
+ <article>
2
+ <% if @page.feature_image.present? %>
3
+ <figure>
4
+ <%= image_tag @page.feature_image, alt: @page.feature_image_alt %>
5
+ <%= content_tag :figcaption, @page.feature_image_caption if @page.feature_image_caption.present? %>
6
+ </figure>
7
+ <% end %>
8
+ <h1><%= @page.title %></h1>
9
+ <p><%= @page.html.html_safe %></p>
10
+ </article>
@@ -0,0 +1,15 @@
1
+ <article>
2
+ <%= image_tag post.feature_image if post.feature_image.present? %>
3
+ <h1><%= link_to_unless_current post.title, post_path(post) %></h1>
4
+ <p><%= post.published_at %></p>
5
+ <% post.authors.each do |author| %>
6
+ <p>by <%= link_to author.name, spooky_engine.author_path(author) %></p>
7
+ <% end %>
8
+ <% if post.tags.any? %>
9
+ <% post.tags.each do |tag| %>
10
+ <%= link_to tag.name, spooky_engine.tag_path(tag) %>
11
+ <% end %>
12
+ <% end %>
13
+ <p><%= post.html.html_safe %></p>
14
+ </article>
15
+
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ config = Rails.application.config.spooky_engine
4
+
5
+ xml.instruct!
6
+ xml.feed "xmlns" => "http://www.w3.org/2005/Atom", "xml:lang" => "en-us" do
7
+ xml.title config.title
8
+ xml.subtitle config.subtitle if config.subtitle
9
+ xml.icon image_url(config.icon) if config.icon
10
+ xml.logo image_url(config.logo) if config.logo
11
+ xml.rights config.rights if config.rights
12
+ xml.generator "SpookyEngine #{Spooky::EngineVersion::VERSION}", uri: "https://github.com/collectiveidea/spooky-engine"
13
+ xml.link href: spooky_engine.root_url, rel: "alternate"
14
+ xml.link href: spooky_engine.atom_url, type: "application/atom+xml", rel: "self"
15
+
16
+ xml.id spooky_engine.atom_url
17
+ xml.updated @posts.first.updated_at.strftime("%Y-%m-%dT%H:%M:%SZ")
18
+
19
+ @posts.each do |post|
20
+ xml.entry do
21
+ xml.id spooky_engine.post_url(post)
22
+ xml.title post.title
23
+ xml.link href: spooky_engine.post_url(post), type: "text/html", rel: "alternate"
24
+ xml.published post.published_at.strftime("%Y-%m-%dT%H:%M:%SZ")
25
+ xml.updated post.updated_at.strftime("%Y-%m-%dT%H:%M:%SZ")
26
+ post.authors.each do |author|
27
+ xml.author do
28
+ xml.name author
29
+ xml.uri spooky_engine.author_url(author)
30
+ end
31
+ end
32
+
33
+ post.tags.each do |tag|
34
+ xml.category term: tag.slug, label: tag.name, scheme: spooky_engine.tag_url(tag)
35
+ end
36
+
37
+ xml.summary(type: "text", "xml:lang": "en") do
38
+ xml.cdata!(post.excerpt.to_s)
39
+ end
40
+
41
+ # TODO make lang configurable or remove?
42
+ # Could get from settings endpoint
43
+ xml.content(type: "html", "xml:lang": "en") do
44
+ xml.cdata!(post.html.to_s)
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,7 @@
1
+ <div>
2
+ <h1>Posts</h1>
3
+
4
+ <%= render @posts %>
5
+ </div>
6
+
7
+ <%= render "spooky/shared/pagination" %>
@@ -0,0 +1,7 @@
1
+ <% if @pagination["pages"] > 1 %>
2
+ <div class="spooky-pagination">
3
+ Page <%= @pagination["page"] %> of <%= @pagination["pages"] %><p>
4
+ <%= link_to "Previous", spooky_pagination_path(page: @pagination["prev"]) if @pagination["prev"].present? %>
5
+ <%= link_to "Next", spooky_pagination_path(page: @pagination["next"]) if @pagination["next"].present? %>
6
+ </div>
7
+ <% end %>
@@ -0,0 +1,14 @@
1
+ <h1>Tags</h1>
2
+
3
+ <ul>
4
+ <% @tags.each do |tag| %>
5
+ <li>
6
+ <%= link_to spooky_engine.tag_path(tag) do %>
7
+ <%= tag.name %>
8
+ (<%= tag.count.posts %>)
9
+ <% end %>
10
+ </li>
11
+ <% end %>
12
+ </ul>
13
+
14
+ <%= render "spooky/shared/pagination" %>
@@ -0,0 +1,3 @@
1
+ <h1><%= @tag.name %></h1>
2
+
3
+
data/config/routes.rb ADDED
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ Spooky::Engine.routes.draw do |x|
4
+ scope module: :spooky do
5
+ resources :authors, only: [:index, :show]
6
+ resources :pages, only: [:index, :show]
7
+ resources :posts, only: [:show] # index is handled by the root
8
+ resources :tags, only: [:index, :show]
9
+ resources :webhooks, only: [:create]
10
+ root to: "posts#index"
11
+ # Note: Having root below atom URL was making post pagination links go to feed instead.
12
+ get "feed.atom", to: "posts#index", defaults: {format: :atom}, as: :atom
13
+ end
14
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spooky"
4
+ require "zeitwerk"
5
+
6
+ loader = Zeitwerk::Loader.for_gem_extension(Spooky)
7
+ loader.setup
8
+
9
+ module Spooky
10
+ class Engine < ::Rails::Engine
11
+ engine_name "spooky-engine"
12
+
13
+ config.spooky_engine = Spooky::EngineConfiguration.new
14
+ end
15
+ end
16
+
17
+ loader.eager_load
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spooky
4
+ class EngineConfiguration
5
+ # Title used in Atom feed. Defaults to "#{Rails.application.name.titleize} Blog"
6
+ attr_writer :title
7
+
8
+ # Optional values used in Atom feed. Defaults to nil
9
+ attr_accessor :subtitle
10
+ attr_accessor :icon
11
+ attr_accessor :logo
12
+ attr_accessor :rights
13
+
14
+ def title
15
+ self.title = "#{Rails.application.name.titleize} Blog"
16
+ end
17
+
18
+ def config
19
+ yield self if block_given?
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spooky
4
+ module EngineVersion
5
+ VERSION = "1.0.0"
6
+ end
7
+ end
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :spooky_engine do
3
+ # # Task goes here
4
+ # end
metadata ADDED
@@ -0,0 +1,85 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: spooky-engine
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Daniel Morrison
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rails
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: 8.0.1
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: 8.0.1
26
+ description: A drop-in engine to quickly put a Ghost blog into a Rails app.
27
+ email:
28
+ - daniel@collectiveidea.com
29
+ executables: []
30
+ extensions: []
31
+ extra_rdoc_files: []
32
+ files:
33
+ - MIT-LICENSE
34
+ - README.md
35
+ - Rakefile
36
+ - app/assets/stylesheets/spooky/application.css
37
+ - app/controllers/spooky/application_controller.rb
38
+ - app/controllers/spooky/authors_controller.rb
39
+ - app/controllers/spooky/pages_controller.rb
40
+ - app/controllers/spooky/posts_controller.rb
41
+ - app/controllers/spooky/tags_controller.rb
42
+ - app/controllers/spooky/webhooks_controller.rb
43
+ - app/helpers/spooky_engine/application_helper.rb
44
+ - app/jobs/spooky_engine/application_job.rb
45
+ - app/views/layouts/spooky/application.html.erb
46
+ - app/views/spooky/authors/index.html.erb
47
+ - app/views/spooky/authors/show.html.erb
48
+ - app/views/spooky/pages/index.html.erb
49
+ - app/views/spooky/pages/show.html.erb
50
+ - app/views/spooky/posts/_post.html.erb
51
+ - app/views/spooky/posts/index.atom.builder
52
+ - app/views/spooky/posts/index.html.erb
53
+ - app/views/spooky/shared/_pagination.html.erb
54
+ - app/views/spooky/tags/index.html.erb
55
+ - app/views/spooky/tags/show.html.erb
56
+ - config/routes.rb
57
+ - lib/spooky/engine.rb
58
+ - lib/spooky/engine_configuration.rb
59
+ - lib/spooky/engine_version.rb
60
+ - lib/tasks/spooky_engine_tasks.rake
61
+ homepage: https://github.com/collectiveidea/spooky_engine
62
+ licenses:
63
+ - MIT
64
+ metadata:
65
+ homepage_uri: https://github.com/collectiveidea/spooky_engine
66
+ source_code_uri: https://github.com/collectiveidea/spooky-engine
67
+ changelog_uri: https://github.com/collectiveidea/spooky-engine/releases
68
+ rdoc_options: []
69
+ require_paths:
70
+ - lib
71
+ required_ruby_version: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ required_rubygems_version: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - ">="
79
+ - !ruby/object:Gem::Version
80
+ version: '0'
81
+ requirements: []
82
+ rubygems_version: 3.6.7
83
+ specification_version: 4
84
+ summary: A drop-in engine to put a Ghost blog into Rails
85
+ test_files: []