draft_forge 0.3.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: 34f09b604f2763d94777f3cd8ae5ad4a1a486dac2663d3d4259c12bf107d7ed1
4
+ data.tar.gz: 323c0bd1ea8e9dbf4111f8a56e48c6e8f2883fc3460e624c35f88a912c01a989
5
+ SHA512:
6
+ metadata.gz: 75143db17f2b1da748254d123e8bf6a96e371acf8c990c9bf04fc67db53056e61dc7355b9b519fbe983603066276efcdfc174cf6b6824e3fefe821b20a45cde3
7
+ data.tar.gz: 07eb51422510955246dea81c90757dc19235f402146a6cb76e8012de42dde0b079ff941c05a6b529068263a71362f520f13cb4ff484c32832916d2d371ce0359
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.3.0
4
+
5
+ - Initial release.
data/MIT-LICENSE ADDED
@@ -0,0 +1,3 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025
data/README.md ADDED
@@ -0,0 +1,73 @@
1
+ # draft_forge
2
+
3
+ Rails engine for exporting HTML or Editor.js JSON to PDF. Can be paired with any front-end or used standalone.
4
+
5
+ Requires Ruby 3.1 or higher. See [CHANGELOG](CHANGELOG.md) for release notes.
6
+
7
+ ## Installation
8
+
9
+ Copy an initializer and migration into your application:
10
+
11
+ ```bash
12
+ bin/rails generate draft_forge:install
13
+ ```
14
+
15
+ Attempting to use DraftForge's services before running the generator and
16
+ migrating will log an error directing you to install and migrate.
17
+
18
+ ## Mounting
19
+
20
+ Expose DraftForge's endpoints by mounting the engine in your application's
21
+ routes:
22
+
23
+ ```ruby
24
+ # config/routes.rb
25
+ Rails.application.routes.draw do
26
+ mount DraftForge::Engine => "/draft_forge"
27
+ end
28
+ ```
29
+
30
+ This provides `POST /draft_forge` to queue a PDF export and
31
+ `GET /draft_forge/:id` to check status or download the finished file.
32
+
33
+ ## Services
34
+
35
+ If you prefer not to expose HTTP endpoints, you can queue and fetch exports
36
+ directly with service objects:
37
+
38
+ ```ruby
39
+ export = DraftForge::CreateExport.call(
40
+ content_json: { blocks: [{ type: 'paragraph', data: { text: 'Hello world' } }] },
41
+ filename: "hello.pdf"
42
+ )
43
+
44
+ DraftForge::FetchExport.call(export.id)
45
+ # => { id: 1, status: "queued" }
46
+ ```
47
+
48
+ ## Configuration
49
+
50
+ `DraftForge` exposes simple configuration hooks for PDF rendering and HTML
51
+ sanitization. The sanitizer controls which HTML elements (blocks) are allowed,
52
+ letting you distinguish between editable and non-editable content. By default
53
+ the sanitizer permits the `contenteditable` attribute so sanitized markup can
54
+ be used in live editors.
55
+
56
+ ```ruby
57
+ # config/initializers/draft_forge.rb
58
+ DraftForge.configure do |config|
59
+ # Change page size/margins for Grover
60
+ config.pdf_options = { format: 'Letter' }
61
+
62
+ # Permit additional HTML elements
63
+ config.sanitizer_config[:elements] += %w[hr]
64
+ end
65
+ ```
66
+
67
+ ## Testing
68
+
69
+ Run the RSpec suite from this directory to verify changes:
70
+
71
+ ```bash
72
+ bundle exec rspec
73
+ ```
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+ module DraftForge
3
+ class ApplicationController < ActionController::Base
4
+ end
5
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+ module DraftForge
3
+ class ExportsController < ApplicationController
4
+ protect_from_forgery with: :null_session
5
+ before_action :ensure_json
6
+
7
+ def create
8
+ html = if params[:content_json].present?
9
+ EditorJsRenderer.call(params[:content_json])
10
+ else
11
+ params[:content_html].to_s
12
+ end
13
+ filename = params[:filename].presence || "document.pdf"
14
+
15
+ export = Export.create!(status: :queued, requested_filename: filename)
16
+ ExportPdfJob.perform_later(export.id, html)
17
+
18
+ render json: { id: export.id, status: export.status }
19
+ rescue => e
20
+ render json: { error: e.message }, status: 422
21
+ end
22
+
23
+ def show
24
+ export = Export.find(params[:id])
25
+ if export.complete? && export.pdf.attached?
26
+ url = Rails.application.routes.url_helpers.rails_blob_url(export.pdf, only_path: false)
27
+ render json: { id: export.id, status: export.status, download_url: url }
28
+ else
29
+ render json: { id: export.id, status: export.status }
30
+ end
31
+ rescue ActiveRecord::RecordNotFound
32
+ render json: { error: "Not found" }, status: 404
33
+ end
34
+
35
+ private
36
+
37
+ def ensure_json
38
+ request.format = :json
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+ require 'grover'
3
+
4
+ module DraftForge
5
+ class ExportPdfJob < ActiveJob::Base
6
+ queue_as :default
7
+
8
+ def perform(export_id, raw_html)
9
+ export = Export.find(export_id)
10
+ export.update!(status: :processing)
11
+
12
+ safe_html = HtmlSanitizer.call(raw_html)
13
+ html = wrap_html(safe_html)
14
+
15
+ pdf = Grover.new(html, DraftForge.pdf_options).to_pdf
16
+
17
+ filename = export.requested_filename.presence || "document.pdf"
18
+ export.pdf.attach(io: StringIO.new(pdf), filename: filename, content_type: 'application/pdf')
19
+ export.update!(status: :complete)
20
+ rescue => e
21
+ Rails.logger.error("[DraftForge] Export failed: #{e.class}: #{e.message}")
22
+ export.update!(status: :failed) rescue nil
23
+ end
24
+
25
+ private
26
+
27
+ def wrap_html(body)
28
+ options = DraftForge.pdf_options
29
+ size = options[:format] || 'A4'
30
+ margin = options[:margin] || {}
31
+ top = margin[:top] || '20mm'
32
+ right = margin[:right] || '15mm'
33
+ bottom = margin[:bottom] || '20mm'
34
+ left = margin[:left] || '15mm'
35
+
36
+ <<~HTML
37
+ <!doctype html>
38
+ <html>
39
+ <head>
40
+ <meta charset="utf-8" />
41
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
42
+ <style>
43
+ @page { size: #{size}; margin: #{top} #{right} #{bottom} #{left}; }
44
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Helvetica Neue', Arial, 'Noto Sans', 'Liberation Sans', sans-serif; font-size: 12px; line-height: 1.45; color: #111; }
45
+ h1, h2, h3 { margin: 0 0 8px; }
46
+ h1 { font-size: 22px; }
47
+ h2 { font-size: 18px; }
48
+ h3 { font-size: 15px; }
49
+ p { margin: 0 0 8px; }
50
+ table { width: 100%; border-collapse: collapse; margin: 8px 0; }
51
+ th, td { border: 1px solid #ccc; padding: 6px 8px; vertical-align: top; }
52
+ img { max-width: 100%; height: auto; }
53
+ .page-break { page-break-after: always; }
54
+ .checklist li { list-style: none; }
55
+ </style>
56
+ </head>
57
+ <body>
58
+ #{body}
59
+ </body>
60
+ </html>
61
+ HTML
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+ module DraftForge
3
+ class Export < ApplicationRecord
4
+ self.table_name = "draft_forge_exports"
5
+
6
+ enum status: { queued: 0, processing: 1, complete: 2, failed: 3 }
7
+ has_one_attached :pdf
8
+ end
9
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DraftForge
4
+ class CreateExport
5
+ def self.call(content_html: nil, content_json: nil, filename: nil)
6
+ unless Export.table_exists?
7
+ Rails.logger.error("[DraftForge] Missing `draft_forge_exports` table. Run `rails generate draft_forge:install` and `rails db:migrate`.")
8
+ return
9
+ end
10
+ html = if content_json
11
+ EditorJsRenderer.call(content_json)
12
+ else
13
+ content_html.to_s
14
+ end
15
+ name = filename.presence || 'document.pdf'
16
+
17
+ export = Export.create!(status: :queued, requested_filename: name)
18
+ ExportPdfJob.perform_later(export.id, html)
19
+
20
+ export
21
+ end
22
+ end
23
+ end
24
+
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module DraftForge
6
+ # Convert Editor.js data to basic HTML
7
+ class EditorJsRenderer
8
+ def self.call(data)
9
+ data = parse(data)
10
+ blocks = data.fetch('blocks', [])
11
+ blocks.map { |block| render_block(block) }.join
12
+ end
13
+
14
+ def self.parse(data)
15
+ if data.is_a?(String)
16
+ JSON.parse(data)
17
+ elsif data.respond_to?(:to_unsafe_h)
18
+ data.to_unsafe_h
19
+ else
20
+ data || {}
21
+ end
22
+ end
23
+
24
+ def self.render_block(block)
25
+ type = block['type']
26
+ bdata = block['data'] || {}
27
+ case type
28
+ when 'paragraph'
29
+ "<p>#{bdata['text']}</p>"
30
+ when 'header'
31
+ level = bdata['level'].to_i
32
+ level = 1 if level < 1
33
+ level = 6 if level > 6
34
+ "<h#{level}>#{bdata['text']}</h#{level}>"
35
+ when 'list'
36
+ tag = bdata['style'] == 'ordered' ? 'ol' : 'ul'
37
+ items = Array(bdata['items']).map { |item| "<li>#{item}</li>" }.join
38
+ "<#{tag}>#{items}</#{tag}>"
39
+ else
40
+ ''
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DraftForge
4
+ class FetchExport
5
+ def self.call(id)
6
+ unless Export.table_exists?
7
+ Rails.logger.error("[DraftForge] Missing `draft_forge_exports` table. Run `rails generate draft_forge:install` and `rails db:migrate`.")
8
+ return
9
+ end
10
+ export = Export.find(id)
11
+ result = { id: export.id, status: export.status }
12
+ if export.complete? && export.pdf.attached?
13
+ url = Rails.application.routes.url_helpers.rails_blob_url(export.pdf, only_path: false)
14
+ result[:download_url] = url
15
+ end
16
+ result
17
+ end
18
+ end
19
+ end
20
+
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+ module DraftForge
3
+ class HtmlSanitizer
4
+ def self.call(html)
5
+ config = DraftForge.sanitizer_config
6
+ Sanitize.fragment(html.to_s, config.merge(remove_contents: ['script', 'style']))
7
+ end
8
+ end
9
+ end
data/config/routes.rb ADDED
@@ -0,0 +1,3 @@
1
+ DraftForge::Engine.routes.draw do
2
+ resources :exports, only: %i[create show], path: ''
3
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails"
4
+ require "sanitize"
5
+
6
+ module DraftForge
7
+ class Engine < ::Rails::Engine
8
+ isolate_namespace DraftForge
9
+ end
10
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+ module DraftForge
3
+ VERSION = "0.3.0"
4
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "draft_forge/version"
4
+ require "draft_forge/engine"
5
+ require "sanitize"
6
+
7
+ module DraftForge
8
+ DEFAULT_SANITIZER_CONFIG = {
9
+ elements: Sanitize::Config::RELAXED[:elements] + %w[table thead tbody tfoot tr td th figure figcaption],
10
+ attributes: {
11
+ 'a' => ['href', 'title', 'target', 'rel'],
12
+ 'img' => ['src', 'alt', 'title', 'width', 'height'],
13
+ 'td' => ['colspan', 'rowspan', 'style'],
14
+ 'th' => ['colspan', 'rowspan', 'style'],
15
+ :all => ['class', 'style', 'contenteditable']
16
+ },
17
+ protocols: {
18
+ 'a' => { 'href' => ['http', 'https', 'mailto', 'tel', :relative] },
19
+ 'img' => { 'src' => ['http', 'https', :relative] }
20
+ },
21
+ transformers: []
22
+ }.freeze
23
+
24
+ mattr_accessor :sanitizer_config, default: DEFAULT_SANITIZER_CONFIG
25
+ mattr_accessor :pdf_options, default: { format: 'A4', margin: { top: '20mm', right: '15mm', bottom: '20mm', left: '15mm' } }
26
+
27
+ def self.configure
28
+ yield self
29
+ end
30
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/migration"
5
+
6
+ module DraftForge
7
+ module Generators
8
+ class InstallGenerator < Rails::Generators::Base
9
+ include Rails::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ def copy_initializer
14
+ template "draft_forge.rb", "config/initializers/draft_forge.rb"
15
+ end
16
+
17
+ def copy_migration
18
+ unless migration_exists?("db/migrate", "create_draft_forge_exports")
19
+ migration_template "create_draft_forge_exports.rb", "db/migrate/create_draft_forge_exports.rb"
20
+ end
21
+ end
22
+
23
+ def self.next_migration_number(dirname)
24
+ Time.now.utc.strftime("%Y%m%d%H%M%S")
25
+ end
26
+
27
+ private
28
+
29
+ def migration_exists?(dir, name)
30
+ Dir.glob(File.join(destination_root, dir, "*_#{name}.rb")).any?
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateDraftForgeExports < ActiveRecord::Migration[7.1]
4
+ def change
5
+ create_table :draft_forge_exports, id: :uuid do |t|
6
+ t.integer :status, null: false, default: 0
7
+ t.string :requested_filename
8
+ t.timestamps
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ DraftForge.configure do |config|
4
+ # Change page size/margins for Grover
5
+ # config.pdf_options = { format: 'Letter' }
6
+
7
+ # Permit additional HTML elements
8
+ # config.sanitizer_config[:elements] += %w[hr]
9
+ end
metadata ADDED
@@ -0,0 +1,114 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: draft_forge
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.3.0
5
+ platform: ruby
6
+ authors:
7
+ - Mike Attara
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2025-08-26 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rails
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '7.1'
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '9.0'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: '7.1'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '9.0'
33
+ - !ruby/object:Gem::Dependency
34
+ name: grover
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '1.1'
40
+ type: :runtime
41
+ prerelease: false
42
+ version_requirements: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '1.1'
47
+ - !ruby/object:Gem::Dependency
48
+ name: sanitize
49
+ requirement: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '6.1'
54
+ type: :runtime
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '6.1'
61
+ description: A minimal Rails engine exposing endpoints to export sanitized HTML to
62
+ PDF via Grover.
63
+ email:
64
+ - mpyebattara@gmail.com
65
+ executables: []
66
+ extensions: []
67
+ extra_rdoc_files: []
68
+ files:
69
+ - CHANGELOG.md
70
+ - MIT-LICENSE
71
+ - README.md
72
+ - app/controllers/draft_forge/application_controller.rb
73
+ - app/controllers/draft_forge/exports_controller.rb
74
+ - app/jobs/draft_forge/export_pdf_job.rb
75
+ - app/models/draft_forge/export.rb
76
+ - app/services/draft_forge/create_export.rb
77
+ - app/services/draft_forge/editor_js_renderer.rb
78
+ - app/services/draft_forge/fetch_export.rb
79
+ - app/services/draft_forge/html_sanitizer.rb
80
+ - config/routes.rb
81
+ - lib/draft_forge.rb
82
+ - lib/draft_forge/engine.rb
83
+ - lib/draft_forge/version.rb
84
+ - lib/generators/draft_forge/install/install_generator.rb
85
+ - lib/generators/draft_forge/install/templates/create_draft_forge_exports.rb
86
+ - lib/generators/draft_forge/install/templates/draft_forge.rb
87
+ homepage: https://github.com/draftforge/draftforge
88
+ licenses:
89
+ - MIT
90
+ metadata:
91
+ homepage_uri: https://github.com/draftforge/draftforge
92
+ source_code_uri: https://github.com/draftforge/draftforge
93
+ changelog_uri: https://github.com/draftforge/draftforge/blob/main/packages/rails/CHANGELOG.md
94
+ rubygems_mfa_required: 'true'
95
+ post_install_message:
96
+ rdoc_options: []
97
+ require_paths:
98
+ - lib
99
+ required_ruby_version: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '3.1'
104
+ required_rubygems_version: !ruby/object:Gem::Requirement
105
+ requirements:
106
+ - - ">="
107
+ - !ruby/object:Gem::Version
108
+ version: '0'
109
+ requirements: []
110
+ rubygems_version: 3.3.27
111
+ signing_key:
112
+ specification_version: 4
113
+ summary: DraftForge — Rails engine for HTML->PDF exports
114
+ test_files: []