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.
Files changed (157) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +41 -0
  3. data/README.md +285 -0
  4. data/app/assets/javascripts/not_pressed/lightbox.js +110 -0
  5. data/app/assets/stylesheets/not_pressed/admin.css +1161 -0
  6. data/app/assets/stylesheets/not_pressed/content.css +193 -0
  7. data/app/assets/stylesheets/not_pressed/gallery.css +117 -0
  8. data/app/assets/stylesheets/not_pressed/themes/starter.css +118 -0
  9. data/app/controllers/not_pressed/admin/base_controller.rb +21 -0
  10. data/app/controllers/not_pressed/admin/categories_controller.rb +53 -0
  11. data/app/controllers/not_pressed/admin/content_blocks_controller.rb +73 -0
  12. data/app/controllers/not_pressed/admin/dashboard_controller.rb +19 -0
  13. data/app/controllers/not_pressed/admin/forms_controller.rb +86 -0
  14. data/app/controllers/not_pressed/admin/media_attachments_controller.rb +94 -0
  15. data/app/controllers/not_pressed/admin/pages_controller.rb +122 -0
  16. data/app/controllers/not_pressed/admin/plugins_controller.rb +121 -0
  17. data/app/controllers/not_pressed/admin/settings_controller.rb +19 -0
  18. data/app/controllers/not_pressed/admin/tags_controller.rb +37 -0
  19. data/app/controllers/not_pressed/admin/themes_controller.rb +104 -0
  20. data/app/controllers/not_pressed/application_controller.rb +6 -0
  21. data/app/controllers/not_pressed/blog_controller.rb +83 -0
  22. data/app/controllers/not_pressed/form_submissions_controller.rb +70 -0
  23. data/app/controllers/not_pressed/pages_controller.rb +36 -0
  24. data/app/controllers/not_pressed/robots_controller.rb +34 -0
  25. data/app/controllers/not_pressed/sitemaps_controller.rb +12 -0
  26. data/app/helpers/not_pressed/admin_helper.rb +41 -0
  27. data/app/helpers/not_pressed/application_helper.rb +6 -0
  28. data/app/helpers/not_pressed/code_injection_helper.rb +29 -0
  29. data/app/helpers/not_pressed/content_helper.rb +13 -0
  30. data/app/helpers/not_pressed/form_helper.rb +80 -0
  31. data/app/helpers/not_pressed/media_helper.rb +28 -0
  32. data/app/helpers/not_pressed/seo_helper.rb +69 -0
  33. data/app/helpers/not_pressed/theme_helper.rb +42 -0
  34. data/app/mailers/not_pressed/application_mailer.rb +10 -0
  35. data/app/mailers/not_pressed/form_mailer.rb +15 -0
  36. data/app/models/concerns/not_pressed/sluggable.rb +43 -0
  37. data/app/models/not_pressed/category.rb +16 -0
  38. data/app/models/not_pressed/content_block.rb +46 -0
  39. data/app/models/not_pressed/form.rb +55 -0
  40. data/app/models/not_pressed/form_field.rb +23 -0
  41. data/app/models/not_pressed/form_submission.rb +19 -0
  42. data/app/models/not_pressed/media_attachment.rb +68 -0
  43. data/app/models/not_pressed/page.rb +182 -0
  44. data/app/models/not_pressed/page_version.rb +15 -0
  45. data/app/models/not_pressed/setting.rb +20 -0
  46. data/app/models/not_pressed/tag.rb +15 -0
  47. data/app/models/not_pressed/tagging.rb +12 -0
  48. data/app/plugins/not_pressed/analytics_plugin.rb +106 -0
  49. data/app/plugins/not_pressed/callout_block_plugin.rb +43 -0
  50. data/app/themes/not_pressed/starter_theme.rb +26 -0
  51. data/app/views/layouts/not_pressed/admin.html.erb +745 -0
  52. data/app/views/layouts/not_pressed/application.html.erb +12 -0
  53. data/app/views/layouts/not_pressed/page.html.erb +22 -0
  54. data/app/views/not_pressed/admin/categories/index.html.erb +58 -0
  55. data/app/views/not_pressed/admin/content_blocks/_block.html.erb +18 -0
  56. data/app/views/not_pressed/admin/content_blocks/_block_picker.html.erb +32 -0
  57. data/app/views/not_pressed/admin/content_blocks/create.turbo_stream.erb +3 -0
  58. data/app/views/not_pressed/admin/content_blocks/destroy.turbo_stream.erb +1 -0
  59. data/app/views/not_pressed/admin/content_blocks/editors/_callout.html.erb +38 -0
  60. data/app/views/not_pressed/admin/content_blocks/editors/_code.html.erb +26 -0
  61. data/app/views/not_pressed/admin/content_blocks/editors/_divider.html.erb +4 -0
  62. data/app/views/not_pressed/admin/content_blocks/editors/_form.html.erb +16 -0
  63. data/app/views/not_pressed/admin/content_blocks/editors/_gallery.html.erb +75 -0
  64. data/app/views/not_pressed/admin/content_blocks/editors/_heading.html.erb +26 -0
  65. data/app/views/not_pressed/admin/content_blocks/editors/_html.html.erb +13 -0
  66. data/app/views/not_pressed/admin/content_blocks/editors/_image.html.erb +56 -0
  67. data/app/views/not_pressed/admin/content_blocks/editors/_quote.html.erb +24 -0
  68. data/app/views/not_pressed/admin/content_blocks/editors/_text.html.erb +28 -0
  69. data/app/views/not_pressed/admin/content_blocks/editors/_video.html.erb +25 -0
  70. data/app/views/not_pressed/admin/dashboard/index.html.erb +60 -0
  71. data/app/views/not_pressed/admin/forms/_field_row.html.erb +33 -0
  72. data/app/views/not_pressed/admin/forms/_form.html.erb +75 -0
  73. data/app/views/not_pressed/admin/forms/edit.html.erb +1 -0
  74. data/app/views/not_pressed/admin/forms/index.html.erb +32 -0
  75. data/app/views/not_pressed/admin/forms/new.html.erb +1 -0
  76. data/app/views/not_pressed/admin/forms/submissions.html.erb +34 -0
  77. data/app/views/not_pressed/admin/media_attachments/_media_card.html.erb +21 -0
  78. data/app/views/not_pressed/admin/media_attachments/_picker.html.erb +19 -0
  79. data/app/views/not_pressed/admin/media_attachments/edit.html.erb +57 -0
  80. data/app/views/not_pressed/admin/media_attachments/index.html.erb +48 -0
  81. data/app/views/not_pressed/admin/pages/_form.html.erb +177 -0
  82. data/app/views/not_pressed/admin/pages/_page_tree_node.html.erb +32 -0
  83. data/app/views/not_pressed/admin/pages/edit.html.erb +69 -0
  84. data/app/views/not_pressed/admin/pages/index.html.erb +21 -0
  85. data/app/views/not_pressed/admin/pages/new.html.erb +1 -0
  86. data/app/views/not_pressed/admin/pages/preview.html.erb +17 -0
  87. data/app/views/not_pressed/admin/plugins/_settings_form.html.erb +59 -0
  88. data/app/views/not_pressed/admin/plugins/index.html.erb +48 -0
  89. data/app/views/not_pressed/admin/plugins/show.html.erb +54 -0
  90. data/app/views/not_pressed/admin/settings/code_injection.html.erb +21 -0
  91. data/app/views/not_pressed/admin/shared/_breadcrumbs.html.erb +14 -0
  92. data/app/views/not_pressed/admin/shared/_flash.html.erb +7 -0
  93. data/app/views/not_pressed/admin/shared/_modal.html.erb +9 -0
  94. data/app/views/not_pressed/admin/shared/_sidebar.html.erb +18 -0
  95. data/app/views/not_pressed/admin/tags/index.html.erb +52 -0
  96. data/app/views/not_pressed/admin/themes/index.html.erb +60 -0
  97. data/app/views/not_pressed/admin/themes/show.html.erb +66 -0
  98. data/app/views/not_pressed/blog/_post_card.html.erb +24 -0
  99. data/app/views/not_pressed/blog/feed.rss.builder +22 -0
  100. data/app/views/not_pressed/blog/index.html.erb +56 -0
  101. data/app/views/not_pressed/blog/show.html.erb +41 -0
  102. data/app/views/not_pressed/form_mailer/submission_notification.text.erb +8 -0
  103. data/app/views/not_pressed/pages/show.html.erb +4 -0
  104. data/app/views/themes/starter/layouts/not_pressed/default.html.erb +36 -0
  105. data/app/views/themes/starter/layouts/not_pressed/full_width.html.erb +36 -0
  106. data/app/views/themes/starter/layouts/not_pressed/sidebar.html.erb +41 -0
  107. data/config/routes.rb +81 -0
  108. data/db/migrate/20260310000001_create_not_pressed_pages.rb +20 -0
  109. data/db/migrate/20260310000002_create_not_pressed_content_blocks.rb +17 -0
  110. data/db/migrate/20260310000003_create_not_pressed_media_attachments.rb +14 -0
  111. data/db/migrate/20260310000004_add_content_type_to_not_pressed_pages.rb +8 -0
  112. data/db/migrate/20260310000005_add_publishing_fields_to_not_pressed_pages.rb +8 -0
  113. data/db/migrate/20260310000006_create_not_pressed_page_versions.rb +16 -0
  114. data/db/migrate/20260310000007_add_settings_fields_to_not_pressed_pages.rb +11 -0
  115. data/db/migrate/20260311000001_create_not_pressed_forms.rb +42 -0
  116. data/db/migrate/20260311000002_add_seo_fields_to_not_pressed_pages.rb +10 -0
  117. data/db/migrate/20260311000003_add_code_injection_to_not_pressed_pages.rb +8 -0
  118. data/db/migrate/20260311000004_create_not_pressed_settings.rb +14 -0
  119. data/db/migrate/20260312000001_create_not_pressed_categories.rb +16 -0
  120. data/db/migrate/20260312000002_create_not_pressed_tags.rb +15 -0
  121. data/db/migrate/20260312000003_create_not_pressed_taggings.rb +14 -0
  122. data/db/migrate/20260312000004_add_category_id_to_not_pressed_pages.rb +7 -0
  123. data/lib/generators/not_pressed/install/install_generator.rb +52 -0
  124. data/lib/generators/not_pressed/install/templates/initializer.rb.tt +89 -0
  125. data/lib/generators/not_pressed/install/templates/seeds.rb.tt +131 -0
  126. data/lib/generators/not_pressed/plugin/plugin_generator.rb +37 -0
  127. data/lib/generators/not_pressed/plugin/templates/plugin.rb.tt +23 -0
  128. data/lib/not_pressed/admin/authentication.rb +48 -0
  129. data/lib/not_pressed/admin/menu_registry.rb +100 -0
  130. data/lib/not_pressed/configuration.rb +77 -0
  131. data/lib/not_pressed/content_type.rb +23 -0
  132. data/lib/not_pressed/content_type_builder.rb +51 -0
  133. data/lib/not_pressed/content_type_registry.rb +45 -0
  134. data/lib/not_pressed/engine.rb +132 -0
  135. data/lib/not_pressed/hooks.rb +166 -0
  136. data/lib/not_pressed/navigation/builder.rb +148 -0
  137. data/lib/not_pressed/navigation/menu.rb +54 -0
  138. data/lib/not_pressed/navigation/menu_item.rb +33 -0
  139. data/lib/not_pressed/navigation/node.rb +45 -0
  140. data/lib/not_pressed/navigation/partial_parser.rb +96 -0
  141. data/lib/not_pressed/navigation/route_inspector.rb +98 -0
  142. data/lib/not_pressed/navigation.rb +6 -0
  143. data/lib/not_pressed/plugin.rb +354 -0
  144. data/lib/not_pressed/plugin_importer.rb +133 -0
  145. data/lib/not_pressed/plugin_manager.rb +196 -0
  146. data/lib/not_pressed/plugin_packager.rb +129 -0
  147. data/lib/not_pressed/rendering/block_renderer.rb +222 -0
  148. data/lib/not_pressed/rendering/renderer_registry.rb +154 -0
  149. data/lib/not_pressed/rendering.rb +8 -0
  150. data/lib/not_pressed/seo/sitemap_builder.rb +61 -0
  151. data/lib/not_pressed/theme.rb +191 -0
  152. data/lib/not_pressed/theme_importer.rb +133 -0
  153. data/lib/not_pressed/theme_packager.rb +180 -0
  154. data/lib/not_pressed/theme_registry.rb +123 -0
  155. data/lib/not_pressed/version.rb +5 -0
  156. data/lib/not_pressed.rb +65 -0
  157. 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,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NotPressed
4
+ module Rendering
5
+ end
6
+ end
7
+
8
+ require_relative "rendering/renderer_registry"
@@ -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