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 +7 -0
- data/CHANGELOG.md +5 -0
- data/MIT-LICENSE +3 -0
- data/README.md +73 -0
- data/app/controllers/draft_forge/application_controller.rb +5 -0
- data/app/controllers/draft_forge/exports_controller.rb +41 -0
- data/app/jobs/draft_forge/export_pdf_job.rb +64 -0
- data/app/models/draft_forge/export.rb +9 -0
- data/app/services/draft_forge/create_export.rb +24 -0
- data/app/services/draft_forge/editor_js_renderer.rb +44 -0
- data/app/services/draft_forge/fetch_export.rb +20 -0
- data/app/services/draft_forge/html_sanitizer.rb +9 -0
- data/config/routes.rb +3 -0
- data/lib/draft_forge/engine.rb +10 -0
- data/lib/draft_forge/version.rb +4 -0
- data/lib/draft_forge.rb +30 -0
- data/lib/generators/draft_forge/install/install_generator.rb +34 -0
- data/lib/generators/draft_forge/install/templates/create_draft_forge_exports.rb +11 -0
- data/lib/generators/draft_forge/install/templates/draft_forge.rb +9 -0
- metadata +114 -0
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/MIT-LICENSE
ADDED
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,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,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
|
+
|
data/config/routes.rb
ADDED
data/lib/draft_forge.rb
ADDED
@@ -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
|
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: []
|