not_pressed-core 0.1.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/LICENSE.txt +41 -0
- data/README.md +285 -0
- data/app/assets/javascripts/not_pressed/lightbox.js +110 -0
- data/app/assets/stylesheets/not_pressed/admin.css +1161 -0
- data/app/assets/stylesheets/not_pressed/content.css +193 -0
- data/app/assets/stylesheets/not_pressed/gallery.css +117 -0
- data/app/assets/stylesheets/not_pressed/themes/starter.css +118 -0
- data/app/controllers/not_pressed/admin/base_controller.rb +21 -0
- data/app/controllers/not_pressed/admin/categories_controller.rb +53 -0
- data/app/controllers/not_pressed/admin/content_blocks_controller.rb +73 -0
- data/app/controllers/not_pressed/admin/dashboard_controller.rb +19 -0
- data/app/controllers/not_pressed/admin/forms_controller.rb +86 -0
- data/app/controllers/not_pressed/admin/media_attachments_controller.rb +94 -0
- data/app/controllers/not_pressed/admin/pages_controller.rb +122 -0
- data/app/controllers/not_pressed/admin/plugins_controller.rb +121 -0
- data/app/controllers/not_pressed/admin/settings_controller.rb +19 -0
- data/app/controllers/not_pressed/admin/tags_controller.rb +37 -0
- data/app/controllers/not_pressed/admin/themes_controller.rb +104 -0
- data/app/controllers/not_pressed/application_controller.rb +6 -0
- data/app/controllers/not_pressed/blog_controller.rb +83 -0
- data/app/controllers/not_pressed/form_submissions_controller.rb +70 -0
- data/app/controllers/not_pressed/pages_controller.rb +36 -0
- data/app/controllers/not_pressed/robots_controller.rb +34 -0
- data/app/controllers/not_pressed/sitemaps_controller.rb +12 -0
- data/app/helpers/not_pressed/admin_helper.rb +41 -0
- data/app/helpers/not_pressed/application_helper.rb +6 -0
- data/app/helpers/not_pressed/code_injection_helper.rb +29 -0
- data/app/helpers/not_pressed/content_helper.rb +13 -0
- data/app/helpers/not_pressed/form_helper.rb +80 -0
- data/app/helpers/not_pressed/media_helper.rb +28 -0
- data/app/helpers/not_pressed/seo_helper.rb +69 -0
- data/app/helpers/not_pressed/theme_helper.rb +42 -0
- data/app/mailers/not_pressed/application_mailer.rb +10 -0
- data/app/mailers/not_pressed/form_mailer.rb +15 -0
- data/app/models/concerns/not_pressed/sluggable.rb +43 -0
- data/app/models/not_pressed/category.rb +16 -0
- data/app/models/not_pressed/content_block.rb +46 -0
- data/app/models/not_pressed/form.rb +55 -0
- data/app/models/not_pressed/form_field.rb +23 -0
- data/app/models/not_pressed/form_submission.rb +19 -0
- data/app/models/not_pressed/media_attachment.rb +68 -0
- data/app/models/not_pressed/page.rb +182 -0
- data/app/models/not_pressed/page_version.rb +15 -0
- data/app/models/not_pressed/setting.rb +20 -0
- data/app/models/not_pressed/tag.rb +15 -0
- data/app/models/not_pressed/tagging.rb +12 -0
- data/app/plugins/not_pressed/analytics_plugin.rb +106 -0
- data/app/plugins/not_pressed/callout_block_plugin.rb +43 -0
- data/app/themes/not_pressed/starter_theme.rb +26 -0
- data/app/views/layouts/not_pressed/admin.html.erb +745 -0
- data/app/views/layouts/not_pressed/application.html.erb +12 -0
- data/app/views/layouts/not_pressed/page.html.erb +22 -0
- data/app/views/not_pressed/admin/categories/index.html.erb +58 -0
- data/app/views/not_pressed/admin/content_blocks/_block.html.erb +18 -0
- data/app/views/not_pressed/admin/content_blocks/_block_picker.html.erb +32 -0
- data/app/views/not_pressed/admin/content_blocks/create.turbo_stream.erb +3 -0
- data/app/views/not_pressed/admin/content_blocks/destroy.turbo_stream.erb +1 -0
- data/app/views/not_pressed/admin/content_blocks/editors/_callout.html.erb +38 -0
- data/app/views/not_pressed/admin/content_blocks/editors/_code.html.erb +26 -0
- data/app/views/not_pressed/admin/content_blocks/editors/_divider.html.erb +4 -0
- data/app/views/not_pressed/admin/content_blocks/editors/_form.html.erb +16 -0
- data/app/views/not_pressed/admin/content_blocks/editors/_gallery.html.erb +75 -0
- data/app/views/not_pressed/admin/content_blocks/editors/_heading.html.erb +26 -0
- data/app/views/not_pressed/admin/content_blocks/editors/_html.html.erb +13 -0
- data/app/views/not_pressed/admin/content_blocks/editors/_image.html.erb +56 -0
- data/app/views/not_pressed/admin/content_blocks/editors/_quote.html.erb +24 -0
- data/app/views/not_pressed/admin/content_blocks/editors/_text.html.erb +28 -0
- data/app/views/not_pressed/admin/content_blocks/editors/_video.html.erb +25 -0
- data/app/views/not_pressed/admin/dashboard/index.html.erb +60 -0
- data/app/views/not_pressed/admin/forms/_field_row.html.erb +33 -0
- data/app/views/not_pressed/admin/forms/_form.html.erb +75 -0
- data/app/views/not_pressed/admin/forms/edit.html.erb +1 -0
- data/app/views/not_pressed/admin/forms/index.html.erb +32 -0
- data/app/views/not_pressed/admin/forms/new.html.erb +1 -0
- data/app/views/not_pressed/admin/forms/submissions.html.erb +34 -0
- data/app/views/not_pressed/admin/media_attachments/_media_card.html.erb +21 -0
- data/app/views/not_pressed/admin/media_attachments/_picker.html.erb +19 -0
- data/app/views/not_pressed/admin/media_attachments/edit.html.erb +57 -0
- data/app/views/not_pressed/admin/media_attachments/index.html.erb +48 -0
- data/app/views/not_pressed/admin/pages/_form.html.erb +177 -0
- data/app/views/not_pressed/admin/pages/_page_tree_node.html.erb +32 -0
- data/app/views/not_pressed/admin/pages/edit.html.erb +69 -0
- data/app/views/not_pressed/admin/pages/index.html.erb +21 -0
- data/app/views/not_pressed/admin/pages/new.html.erb +1 -0
- data/app/views/not_pressed/admin/pages/preview.html.erb +17 -0
- data/app/views/not_pressed/admin/plugins/_settings_form.html.erb +59 -0
- data/app/views/not_pressed/admin/plugins/index.html.erb +48 -0
- data/app/views/not_pressed/admin/plugins/show.html.erb +54 -0
- data/app/views/not_pressed/admin/settings/code_injection.html.erb +21 -0
- data/app/views/not_pressed/admin/shared/_breadcrumbs.html.erb +14 -0
- data/app/views/not_pressed/admin/shared/_flash.html.erb +7 -0
- data/app/views/not_pressed/admin/shared/_modal.html.erb +9 -0
- data/app/views/not_pressed/admin/shared/_sidebar.html.erb +18 -0
- data/app/views/not_pressed/admin/tags/index.html.erb +52 -0
- data/app/views/not_pressed/admin/themes/index.html.erb +60 -0
- data/app/views/not_pressed/admin/themes/show.html.erb +66 -0
- data/app/views/not_pressed/blog/_post_card.html.erb +24 -0
- data/app/views/not_pressed/blog/feed.rss.builder +22 -0
- data/app/views/not_pressed/blog/index.html.erb +56 -0
- data/app/views/not_pressed/blog/show.html.erb +41 -0
- data/app/views/not_pressed/form_mailer/submission_notification.text.erb +8 -0
- data/app/views/not_pressed/pages/show.html.erb +4 -0
- data/app/views/themes/starter/layouts/not_pressed/default.html.erb +36 -0
- data/app/views/themes/starter/layouts/not_pressed/full_width.html.erb +36 -0
- data/app/views/themes/starter/layouts/not_pressed/sidebar.html.erb +41 -0
- data/config/routes.rb +81 -0
- data/db/migrate/20260310000001_create_not_pressed_pages.rb +20 -0
- data/db/migrate/20260310000002_create_not_pressed_content_blocks.rb +17 -0
- data/db/migrate/20260310000003_create_not_pressed_media_attachments.rb +14 -0
- data/db/migrate/20260310000004_add_content_type_to_not_pressed_pages.rb +8 -0
- data/db/migrate/20260310000005_add_publishing_fields_to_not_pressed_pages.rb +8 -0
- data/db/migrate/20260310000006_create_not_pressed_page_versions.rb +16 -0
- data/db/migrate/20260310000007_add_settings_fields_to_not_pressed_pages.rb +11 -0
- data/db/migrate/20260311000001_create_not_pressed_forms.rb +42 -0
- data/db/migrate/20260311000002_add_seo_fields_to_not_pressed_pages.rb +10 -0
- data/db/migrate/20260311000003_add_code_injection_to_not_pressed_pages.rb +8 -0
- data/db/migrate/20260311000004_create_not_pressed_settings.rb +14 -0
- data/db/migrate/20260312000001_create_not_pressed_categories.rb +16 -0
- data/db/migrate/20260312000002_create_not_pressed_tags.rb +15 -0
- data/db/migrate/20260312000003_create_not_pressed_taggings.rb +14 -0
- data/db/migrate/20260312000004_add_category_id_to_not_pressed_pages.rb +7 -0
- data/lib/generators/not_pressed/install/install_generator.rb +52 -0
- data/lib/generators/not_pressed/install/templates/initializer.rb.tt +89 -0
- data/lib/generators/not_pressed/install/templates/seeds.rb.tt +131 -0
- data/lib/generators/not_pressed/plugin/plugin_generator.rb +37 -0
- data/lib/generators/not_pressed/plugin/templates/plugin.rb.tt +23 -0
- data/lib/not_pressed/admin/authentication.rb +48 -0
- data/lib/not_pressed/admin/menu_registry.rb +100 -0
- data/lib/not_pressed/configuration.rb +77 -0
- data/lib/not_pressed/content_type.rb +23 -0
- data/lib/not_pressed/content_type_builder.rb +51 -0
- data/lib/not_pressed/content_type_registry.rb +45 -0
- data/lib/not_pressed/engine.rb +132 -0
- data/lib/not_pressed/hooks.rb +166 -0
- data/lib/not_pressed/navigation/builder.rb +148 -0
- data/lib/not_pressed/navigation/menu.rb +54 -0
- data/lib/not_pressed/navigation/menu_item.rb +33 -0
- data/lib/not_pressed/navigation/node.rb +45 -0
- data/lib/not_pressed/navigation/partial_parser.rb +96 -0
- data/lib/not_pressed/navigation/route_inspector.rb +98 -0
- data/lib/not_pressed/navigation.rb +6 -0
- data/lib/not_pressed/plugin.rb +354 -0
- data/lib/not_pressed/plugin_importer.rb +133 -0
- data/lib/not_pressed/plugin_manager.rb +196 -0
- data/lib/not_pressed/plugin_packager.rb +129 -0
- data/lib/not_pressed/rendering/block_renderer.rb +222 -0
- data/lib/not_pressed/rendering/renderer_registry.rb +154 -0
- data/lib/not_pressed/rendering.rb +8 -0
- data/lib/not_pressed/seo/sitemap_builder.rb +61 -0
- data/lib/not_pressed/theme.rb +191 -0
- data/lib/not_pressed/theme_importer.rb +133 -0
- data/lib/not_pressed/theme_packager.rb +180 -0
- data/lib/not_pressed/theme_registry.rb +123 -0
- data/lib/not_pressed/version.rb +5 -0
- data/lib/not_pressed.rb +65 -0
- metadata +258 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "zip"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module NotPressed
|
|
7
|
+
# Exports a registered plugin into a distributable zip archive.
|
|
8
|
+
#
|
|
9
|
+
# The archive contains a manifest.json with plugin metadata and all
|
|
10
|
+
# associated source files (plugin class file and editor partials).
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# packager = NotPressed::PluginPackager.new("Analytics")
|
|
14
|
+
# zip_path = packager.package("/tmp/exports")
|
|
15
|
+
class PluginPackager
|
|
16
|
+
# @param plugin_name [String] the registered plugin name
|
|
17
|
+
def initialize(plugin_name)
|
|
18
|
+
@plugin_name = plugin_name
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Creates a zip archive of the plugin at the given output directory.
|
|
22
|
+
#
|
|
23
|
+
# @param output_path [String] directory where the zip will be written
|
|
24
|
+
# @return [String] absolute path to the created zip file
|
|
25
|
+
# @raise [PluginManager::NotFound] if the plugin is not registered
|
|
26
|
+
# @raise [ArgumentError] if output_path does not exist
|
|
27
|
+
def package(output_path)
|
|
28
|
+
entry = PluginManager.find(@plugin_name)
|
|
29
|
+
klass = entry[:klass]
|
|
30
|
+
|
|
31
|
+
raise ArgumentError, "Output directory does not exist: #{output_path}" unless Dir.exist?(output_path)
|
|
32
|
+
|
|
33
|
+
files = collect_files(klass)
|
|
34
|
+
manifest = build_manifest(klass, files)
|
|
35
|
+
|
|
36
|
+
zip_filename = "#{snake_name(klass)}-#{klass.plugin_version || '0.0.0'}.zip"
|
|
37
|
+
zip_path = File.join(output_path, zip_filename)
|
|
38
|
+
|
|
39
|
+
root = engine_root
|
|
40
|
+
|
|
41
|
+
Zip::File.open(zip_path, create: true) do |zip|
|
|
42
|
+
zip.get_output_stream("manifest.json") { |io| io.write(JSON.pretty_generate(manifest)) }
|
|
43
|
+
|
|
44
|
+
files.each do |relative_path|
|
|
45
|
+
absolute = File.join(root, relative_path)
|
|
46
|
+
zip.add(relative_path, absolute) if File.exist?(absolute)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
zip_path
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
# Collects all files that belong to the plugin.
|
|
56
|
+
#
|
|
57
|
+
# @param klass [Class] the plugin class
|
|
58
|
+
# @return [Array<String>] relative file paths from engine root
|
|
59
|
+
def collect_files(klass)
|
|
60
|
+
files = []
|
|
61
|
+
root = engine_root
|
|
62
|
+
|
|
63
|
+
# Plugin class file
|
|
64
|
+
plugin_file = "app/plugins/not_pressed/#{snake_name(klass)}_plugin.rb"
|
|
65
|
+
files << plugin_file if File.exist?(File.join(root, plugin_file))
|
|
66
|
+
|
|
67
|
+
# Editor partials for each block type
|
|
68
|
+
klass.plugin_block_types.each do |bt|
|
|
69
|
+
bt_name = bt[:name].to_s
|
|
70
|
+
pattern = File.join(root, "app/views/not_pressed/admin/content_blocks/editors/*#{bt_name}*")
|
|
71
|
+
Dir.glob(pattern).each do |path|
|
|
72
|
+
relative = path.sub("#{root}/", "").tr("\\", "/")
|
|
73
|
+
files << relative
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
files
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Builds the manifest hash from plugin DSL metadata.
|
|
81
|
+
#
|
|
82
|
+
# @param klass [Class] the plugin class
|
|
83
|
+
# @param files [Array<String>] list of files included in the archive
|
|
84
|
+
# @return [Hash] manifest data
|
|
85
|
+
def build_manifest(klass, files)
|
|
86
|
+
{
|
|
87
|
+
name: klass.plugin_name,
|
|
88
|
+
version: klass.plugin_version,
|
|
89
|
+
description: klass.plugin_description,
|
|
90
|
+
author: klass.plugin_author,
|
|
91
|
+
url: klass.plugin_url,
|
|
92
|
+
dependencies: (klass.plugin_dependencies || []).map { |d| { name: d[:name], constraint: d[:constraint] } },
|
|
93
|
+
settings_schema: (klass.plugin_settings_schema || []).map { |s| stringify_keys(s) },
|
|
94
|
+
block_types: (klass.plugin_block_types || []).map { |bt| { name: bt[:name].to_s, options: stringify_keys(bt[:options]) } },
|
|
95
|
+
files: files
|
|
96
|
+
}
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Converts a plugin class to its snake_case file name stem.
|
|
100
|
+
#
|
|
101
|
+
# @param klass [Class] the plugin class
|
|
102
|
+
# @return [String] snake_case name
|
|
103
|
+
def snake_name(klass)
|
|
104
|
+
# Use Module#name directly to bypass the Plugin DSL `name` override
|
|
105
|
+
Module.instance_method(:name).bind_call(klass).split("::").last
|
|
106
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
107
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
108
|
+
.downcase
|
|
109
|
+
.sub(/_plugin$/, "")
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Returns the engine root directory.
|
|
113
|
+
#
|
|
114
|
+
# @return [String]
|
|
115
|
+
def engine_root
|
|
116
|
+
NotPressed::Engine.root.to_s
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Deep-converts symbol keys to strings for JSON serialization.
|
|
120
|
+
#
|
|
121
|
+
# @param hash [Hash] input hash
|
|
122
|
+
# @return [Hash] hash with string keys
|
|
123
|
+
def stringify_keys(hash)
|
|
124
|
+
hash.each_with_object({}) do |(k, v), h|
|
|
125
|
+
h[k.to_s] = v.is_a?(Hash) ? stringify_keys(v) : v
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "action_view"
|
|
4
|
+
|
|
5
|
+
module NotPressed
|
|
6
|
+
module Rendering
|
|
7
|
+
# Renders individual content blocks and full pages to HTML.
|
|
8
|
+
#
|
|
9
|
+
# Delegates to {RendererRegistry} for type-specific rendering and applies
|
|
10
|
+
# hook filters before and after rendering.
|
|
11
|
+
class BlockRenderer
|
|
12
|
+
ALLOWED_TEXT_TAGS = %w[p br strong em a ul ol li h1 h2 h3 h4 h5 h6 span div].freeze
|
|
13
|
+
ALLOWED_TEXT_ATTRIBUTES = %w[href target rel class id].freeze
|
|
14
|
+
|
|
15
|
+
YOUTUBE_REGEX = %r{(?:youtube\.com/watch\?v=|youtu\.be/|youtube\.com/embed/)([\w-]+)}
|
|
16
|
+
VIMEO_REGEX = %r{(?:vimeo\.com/)(\d+)}
|
|
17
|
+
|
|
18
|
+
class << self
|
|
19
|
+
# Renders a single content block to HTML.
|
|
20
|
+
#
|
|
21
|
+
# Applies the +:block_content_before_render+ filter to the content hash
|
|
22
|
+
# and the +:block_content_after_render+ filter to the rendered HTML.
|
|
23
|
+
#
|
|
24
|
+
# @param block [NotPressed::ContentBlock] the content block to render
|
|
25
|
+
# @return [ActiveSupport::SafeBuffer] rendered HTML
|
|
26
|
+
def render(block)
|
|
27
|
+
content = NotPressed::Hooks.apply_filter(:block_content_before_render, (block.content || {}).dup, block)
|
|
28
|
+
html = render_by_type(block.block_type.to_s, content, block)
|
|
29
|
+
html = NotPressed::Hooks.apply_filter(:block_content_after_render, html, block)
|
|
30
|
+
html.html_safe
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Renders all ordered content blocks for a page.
|
|
34
|
+
#
|
|
35
|
+
# Applies the +:page_content+ filter to the combined HTML output.
|
|
36
|
+
#
|
|
37
|
+
# @param page [NotPressed::Page] the page whose blocks to render
|
|
38
|
+
# @return [ActiveSupport::SafeBuffer] rendered HTML (empty string if no blocks)
|
|
39
|
+
def render_page(page)
|
|
40
|
+
blocks = page.content_blocks.ordered
|
|
41
|
+
return "".html_safe if blocks.empty?
|
|
42
|
+
|
|
43
|
+
html = blocks.map { |block| render(block) }.join("\n")
|
|
44
|
+
html = NotPressed::Hooks.apply_filter(:page_content, html, page)
|
|
45
|
+
html.html_safe
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def render_by_type(block_type, content, block)
|
|
51
|
+
RendererRegistry.render(block_type, content, block)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def render_text(content)
|
|
55
|
+
body = sanitize_html(content["body"].to_s)
|
|
56
|
+
%(<div class="np-content-text">#{body}</div>)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def render_heading(content)
|
|
60
|
+
text = escape(content["text"].to_s)
|
|
61
|
+
level = content["level"].to_i
|
|
62
|
+
level = 2 unless (1..6).cover?(level)
|
|
63
|
+
%(<h#{level} class="np-content-heading">#{text}</h#{level}>)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def render_image(content)
|
|
67
|
+
url = resolve_image_url(content)
|
|
68
|
+
alt = escape(content["alt"].to_s)
|
|
69
|
+
caption = content["caption"].to_s.strip
|
|
70
|
+
|
|
71
|
+
html = %(<figure class="np-content-image"><img src="#{escape(url)}" alt="#{alt}">)
|
|
72
|
+
html += %(<figcaption>#{escape(caption)}</figcaption>) unless caption.empty?
|
|
73
|
+
html += %(</figure>)
|
|
74
|
+
html
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def resolve_image_url(content)
|
|
78
|
+
if content["media_id"].present?
|
|
79
|
+
media = NotPressed::MediaAttachment.find_by(id: content["media_id"])
|
|
80
|
+
if media&.file&.attached?
|
|
81
|
+
return Rails.application.routes.url_helpers.rails_blob_path(media.file, disposition: "inline", only_path: true)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
content["url"].to_s
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def render_video(content)
|
|
89
|
+
url = content["url"].to_s
|
|
90
|
+
caption = content["caption"].to_s.strip
|
|
91
|
+
|
|
92
|
+
html = %(<div class="np-content-video">)
|
|
93
|
+
|
|
94
|
+
if (match = url.match(YOUTUBE_REGEX))
|
|
95
|
+
video_id = match[1]
|
|
96
|
+
html += %(<iframe src="https://www.youtube.com/embed/#{video_id}" frameborder="0" allowfullscreen></iframe>)
|
|
97
|
+
elsif (match = url.match(VIMEO_REGEX))
|
|
98
|
+
video_id = match[1]
|
|
99
|
+
html += %(<iframe src="https://player.vimeo.com/video/#{video_id}" frameborder="0" allowfullscreen></iframe>)
|
|
100
|
+
else
|
|
101
|
+
html += %(<video src="#{escape(url)}" controls></video>)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
html += %(<figcaption>#{escape(caption)}</figcaption>) unless caption.empty?
|
|
105
|
+
html += %(</div>)
|
|
106
|
+
html
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def render_code(content)
|
|
110
|
+
code = escape(content["code"].to_s)
|
|
111
|
+
language = content["language"].to_s.strip
|
|
112
|
+
lang_class = language.empty? ? "" : %( class="language-#{escape(language)}")
|
|
113
|
+
%(<pre class="np-content-code"><code#{lang_class}>#{code}</code></pre>)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def render_quote(content)
|
|
117
|
+
text = escape(content["text"].to_s)
|
|
118
|
+
attribution = content["attribution"].to_s.strip
|
|
119
|
+
|
|
120
|
+
html = %(<blockquote class="np-content-quote"><p>#{text}</p>)
|
|
121
|
+
html += %(<cite>#{escape(attribution)}</cite>) unless attribution.empty?
|
|
122
|
+
html += %(</blockquote>)
|
|
123
|
+
html
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def render_divider
|
|
127
|
+
%(<hr class="np-content-divider">)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def render_html(content)
|
|
131
|
+
markup = content["markup"].to_s
|
|
132
|
+
%(<div class="np-content-html">#{markup}</div>)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def render_form_block(content)
|
|
136
|
+
form_id = content["form_id"]
|
|
137
|
+
return %(<div class="np-content-form"></div>) unless form_id
|
|
138
|
+
|
|
139
|
+
form = NotPressed::Form.find_by(id: form_id)
|
|
140
|
+
return %(<div class="np-content-form">Form not found</div>) unless form&.status_active?
|
|
141
|
+
|
|
142
|
+
action_url = NotPressed::Engine.routes.url_helpers.form_submissions_path(form_slug: form.slug)
|
|
143
|
+
fields_html = form.form_fields.ordered.map { |field| render_form_field_html(field) }.join("\n")
|
|
144
|
+
|
|
145
|
+
form_html = %(<form action="#{action_url}" method="post" class="np-public-form">
|
|
146
|
+
#{fields_html}
|
|
147
|
+
<div class="np-form-submit"><input type="submit" value="Submit" class="np-form-submit-btn"></div>
|
|
148
|
+
</form>)
|
|
149
|
+
|
|
150
|
+
%(<div class="np-content-form">#{form_html}</div>)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def render_form_field_html(field)
|
|
154
|
+
fname = escape(field.label.parameterize(separator: "_"))
|
|
155
|
+
fid = "field_#{field.id}"
|
|
156
|
+
req = field.required? ? ' required="required"' : ""
|
|
157
|
+
placeholder = escape(field.placeholder.to_s)
|
|
158
|
+
|
|
159
|
+
input_html = case field.field_type.to_s
|
|
160
|
+
when "text", "email", "number", "url", "date"
|
|
161
|
+
%(<input type="#{field.field_type}" name="submission[#{fname}]" id="#{fid}" class="np-form-input" placeholder="#{placeholder}"#{req}>)
|
|
162
|
+
when "phone"
|
|
163
|
+
%(<input type="tel" name="submission[#{fname}]" id="#{fid}" class="np-form-input" placeholder="#{placeholder}"#{req}>)
|
|
164
|
+
when "textarea"
|
|
165
|
+
%(<textarea name="submission[#{fname}]" id="#{fid}" class="np-form-input" placeholder="#{placeholder}" rows="4"#{req}></textarea>)
|
|
166
|
+
when "select"
|
|
167
|
+
options = (field.options || "").split("\n").map(&:strip).reject(&:blank?)
|
|
168
|
+
opts = %(<option value="">Select...</option>) + options.map { |o| %(<option value="#{escape(o)}">#{escape(o)}</option>) }.join
|
|
169
|
+
%(<select name="submission[#{fname}]" id="#{fid}" class="np-form-input"#{req}>#{opts}</select>)
|
|
170
|
+
when "radio"
|
|
171
|
+
options = (field.options || "").split("\n").map(&:strip).reject(&:blank?)
|
|
172
|
+
options.map { |o| %(<label class="np-form-radio-label"><input type="radio" name="submission[#{fname}]" value="#{escape(o)}"#{req}> #{escape(o)}</label>) }.join("\n")
|
|
173
|
+
when "checkbox"
|
|
174
|
+
options = (field.options || "").split("\n").map(&:strip).reject(&:blank?)
|
|
175
|
+
if options.any?
|
|
176
|
+
options.map { |o| %(<label class="np-form-checkbox-label"><input type="checkbox" name="submission[#{fname}][]" value="#{escape(o)}"> #{escape(o)}</label>) }.join("\n")
|
|
177
|
+
else
|
|
178
|
+
%(<input type="checkbox" name="submission[#{fname}]" id="#{fid}" value="1"#{req}>)
|
|
179
|
+
end
|
|
180
|
+
when "file_upload"
|
|
181
|
+
%(<input type="file" name="submission[#{fname}]" id="#{fid}" class="np-form-input"#{req}>)
|
|
182
|
+
else
|
|
183
|
+
%(<input type="text" name="submission[#{fname}]" id="#{fid}" class="np-form-input"#{req}>)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
%(<div class="np-form-field">
|
|
187
|
+
<label class="np-form-label" for="#{fid}">#{escape(field.label)}#{" *" if field.required?}</label>
|
|
188
|
+
#{input_html}
|
|
189
|
+
</div>)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def render_unknown(block_type)
|
|
193
|
+
%(<div class="np-content-unknown">Unknown block type: #{escape(block_type.to_s)}</div>)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def register_built_in_renderers
|
|
197
|
+
%w[text heading image video code quote html].each do |type|
|
|
198
|
+
method_ref = method(:"render_#{type}")
|
|
199
|
+
RendererRegistry.register_built_in(type, ->(content, _block) { method_ref.call(content) })
|
|
200
|
+
end
|
|
201
|
+
RendererRegistry.register_built_in("divider", ->(_content, _block) { render_divider })
|
|
202
|
+
RendererRegistry.register_built_in("form", ->(content, _block) { render_form_block(content) })
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def escape(text)
|
|
206
|
+
ERB::Util.html_escape(text)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def sanitize_html(html)
|
|
210
|
+
ActionView::Base.safe_list_sanitizer.sanitize(
|
|
211
|
+
html,
|
|
212
|
+
tags: ALLOWED_TEXT_TAGS,
|
|
213
|
+
attributes: ALLOWED_TEXT_ATTRIBUTES
|
|
214
|
+
)
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Register built-in renderers at load time
|
|
219
|
+
register_built_in_renderers
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NotPressed
|
|
4
|
+
module Rendering
|
|
5
|
+
# Registry for custom and built-in block type renderers, editor partials,
|
|
6
|
+
# and default content.
|
|
7
|
+
#
|
|
8
|
+
# Custom renderers take priority over built-in renderers. Each renderer
|
|
9
|
+
# must respond to +#call(content, block_record)+.
|
|
10
|
+
#
|
|
11
|
+
# @example Registering a custom renderer
|
|
12
|
+
# RendererRegistry.register("gallery") do |content, block|
|
|
13
|
+
# "<div class='gallery'>#{content['images'].size} images</div>"
|
|
14
|
+
# end
|
|
15
|
+
class RendererRegistry
|
|
16
|
+
class << self
|
|
17
|
+
# Registers a custom renderer for a block type.
|
|
18
|
+
#
|
|
19
|
+
# Custom renderers take priority over built-in renderers.
|
|
20
|
+
#
|
|
21
|
+
# @param block_type [String, Symbol] block type identifier
|
|
22
|
+
# @param renderer [#call, nil] callable renderer, or pass a block instead
|
|
23
|
+
# @yield [content, block_record] alternative to the renderer parameter
|
|
24
|
+
# @raise [ArgumentError] if the renderer does not respond to +#call+
|
|
25
|
+
# @return [void]
|
|
26
|
+
def register(block_type, renderer = nil, &block)
|
|
27
|
+
renderer = block if block_given?
|
|
28
|
+
raise ArgumentError, "Renderer must respond to #call" unless renderer.respond_to?(:call)
|
|
29
|
+
|
|
30
|
+
custom_renderers[block_type.to_s] = renderer
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Renders a block using the appropriate renderer.
|
|
34
|
+
#
|
|
35
|
+
# Looks up custom renderers first, then built-in, then falls back
|
|
36
|
+
# to a generic "unknown type" message.
|
|
37
|
+
#
|
|
38
|
+
# @param block_type [String] block type identifier
|
|
39
|
+
# @param content [Hash] block content data
|
|
40
|
+
# @param block_record [NotPressed::ContentBlock] the content block record
|
|
41
|
+
# @return [String] rendered HTML
|
|
42
|
+
def render(block_type, content, block_record)
|
|
43
|
+
type = block_type.to_s
|
|
44
|
+
renderer = custom_renderers[type] || built_in_renderers[type]
|
|
45
|
+
|
|
46
|
+
if renderer
|
|
47
|
+
renderer.call(content, block_record)
|
|
48
|
+
else
|
|
49
|
+
render_fallback(type, content)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Checks whether a renderer exists for the block type.
|
|
54
|
+
#
|
|
55
|
+
# @param block_type [String, Symbol] block type identifier
|
|
56
|
+
# @return [Boolean]
|
|
57
|
+
def registered?(block_type)
|
|
58
|
+
type = block_type.to_s
|
|
59
|
+
custom_renderers.key?(type) || built_in_renderers.key?(type)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Clears all custom renderers, editor partials, and default content.
|
|
63
|
+
#
|
|
64
|
+
# @return [void]
|
|
65
|
+
def reset!
|
|
66
|
+
custom_renderers.clear
|
|
67
|
+
editor_partials.clear
|
|
68
|
+
default_contents.clear
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Removes the custom renderer, editor partial, and default content
|
|
72
|
+
# for a block type.
|
|
73
|
+
#
|
|
74
|
+
# @param block_type [String, Symbol] block type identifier
|
|
75
|
+
# @return [void]
|
|
76
|
+
def unregister(block_type)
|
|
77
|
+
type = block_type.to_s
|
|
78
|
+
custom_renderers.delete(type)
|
|
79
|
+
editor_partials.delete(type)
|
|
80
|
+
default_contents.delete(type)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Registers a built-in renderer (lower priority than custom renderers).
|
|
84
|
+
#
|
|
85
|
+
# @param block_type [String] block type identifier
|
|
86
|
+
# @param renderer [#call] callable renderer
|
|
87
|
+
# @return [void]
|
|
88
|
+
def register_built_in(block_type, renderer)
|
|
89
|
+
built_in_renderers[block_type.to_s] = renderer
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Registers a custom editor partial path for a block type.
|
|
93
|
+
#
|
|
94
|
+
# @param block_type [String, Symbol] block type identifier
|
|
95
|
+
# @param partial_path [String] path to the editor partial
|
|
96
|
+
# @return [void]
|
|
97
|
+
def register_editor(block_type, partial_path)
|
|
98
|
+
editor_partials[block_type.to_s] = partial_path
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Returns the editor partial path for a block type.
|
|
102
|
+
#
|
|
103
|
+
# Falls back to +not_pressed/admin/content_blocks/editors/<type>+.
|
|
104
|
+
#
|
|
105
|
+
# @param block_type [String, Symbol] block type identifier
|
|
106
|
+
# @return [String] partial path
|
|
107
|
+
def editor_partial(block_type)
|
|
108
|
+
type = block_type.to_s
|
|
109
|
+
editor_partials[type] || "not_pressed/admin/content_blocks/editors/#{type}"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Registers default content for new blocks of the given type.
|
|
113
|
+
#
|
|
114
|
+
# @param block_type [String, Symbol] block type identifier
|
|
115
|
+
# @param content_hash [Hash] default content structure
|
|
116
|
+
# @return [void]
|
|
117
|
+
def register_default_content(block_type, content_hash)
|
|
118
|
+
default_contents[block_type.to_s] = content_hash
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Returns the default content hash for a block type.
|
|
122
|
+
#
|
|
123
|
+
# @param block_type [String, Symbol] block type identifier
|
|
124
|
+
# @return [Hash, nil] the default content, or nil if none registered
|
|
125
|
+
def default_content(block_type)
|
|
126
|
+
default_contents[block_type.to_s]
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
def custom_renderers
|
|
132
|
+
@custom_renderers ||= {}
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def built_in_renderers
|
|
136
|
+
@built_in_renderers ||= {}
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def editor_partials
|
|
140
|
+
@editor_partials ||= {}
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def default_contents
|
|
144
|
+
@default_contents ||= {}
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def render_fallback(block_type, _content)
|
|
148
|
+
escaped_type = ERB::Util.html_escape(block_type)
|
|
149
|
+
%(<div class="np-content-unknown">Unknown block type: #{escaped_type}</div>)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi"
|
|
4
|
+
|
|
5
|
+
module NotPressed
|
|
6
|
+
module Seo
|
|
7
|
+
class SitemapBuilder
|
|
8
|
+
XMLNS = "http://www.sitemaps.org/schemas/sitemap/0.9"
|
|
9
|
+
|
|
10
|
+
def initialize(base_url:)
|
|
11
|
+
@base_url = base_url.chomp("/")
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def generate
|
|
15
|
+
pages = NotPressed::Page.visible.publicly_accessible.includes(:parent)
|
|
16
|
+
|
|
17
|
+
urls = pages.map { |page| url_entry(page) }.join
|
|
18
|
+
|
|
19
|
+
%(<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="#{XMLNS}">\n#{urls}</urlset>\n)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def url_entry(page)
|
|
25
|
+
loc = CGI.escapeHTML("#{@base_url}/#{page.slug}")
|
|
26
|
+
lastmod = page.updated_at.iso8601
|
|
27
|
+
priority = priority_for(page)
|
|
28
|
+
|
|
29
|
+
" <url>\n" \
|
|
30
|
+
" <loc>#{loc}</loc>\n" \
|
|
31
|
+
" <lastmod>#{lastmod}</lastmod>\n" \
|
|
32
|
+
" <changefreq>weekly</changefreq>\n" \
|
|
33
|
+
" <priority>#{priority}</priority>\n" \
|
|
34
|
+
" </url>\n"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def priority_for(page)
|
|
38
|
+
depth = compute_depth(page)
|
|
39
|
+
|
|
40
|
+
case depth
|
|
41
|
+
when 0 then "1.0"
|
|
42
|
+
when 1 then "0.8"
|
|
43
|
+
when 2 then "0.6"
|
|
44
|
+
else "0.5"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def compute_depth(page)
|
|
49
|
+
depth = 0
|
|
50
|
+
current = page
|
|
51
|
+
|
|
52
|
+
while current.parent_id.present?
|
|
53
|
+
depth += 1
|
|
54
|
+
current = current.parent
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
depth
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|