iron-cms 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 (240) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +174 -0
  4. data/Rakefile +8 -0
  5. data/app/assets/builds/iron.css +2 -0
  6. data/app/assets/config/iron_manifest.js +1 -0
  7. data/app/assets/images/iron/icons/sprite.svg +9425 -0
  8. data/app/assets/tailwind/iron/actiontext.css +550 -0
  9. data/app/assets/tailwind/iron/application.css +66 -0
  10. data/app/assets/tailwind/iron/components/button.css +74 -0
  11. data/app/assets/tailwind/iron/components/dropdown.css +42 -0
  12. data/app/assets/tailwind/iron/components/fieldset.css +18 -0
  13. data/app/assets/tailwind/iron/components/form.css +14 -0
  14. data/app/assets/tailwind/iron/components/icon-picker.css +9 -0
  15. data/app/assets/tailwind/iron/components/input.css +44 -0
  16. data/app/assets/tailwind/iron/components/page.css +7 -0
  17. data/app/assets/tailwind/iron/components/select.css +36 -0
  18. data/app/assets/tailwind/iron/components/sidebar.css +21 -0
  19. data/app/assets/tailwind/iron/components/textarea.css +27 -0
  20. data/app/controllers/concerns/iron/authentication.rb +66 -0
  21. data/app/controllers/concerns/iron/authorization.rb +11 -0
  22. data/app/controllers/concerns/iron/locale_aware.rb +20 -0
  23. data/app/controllers/concerns/iron/web_page.rb +18 -0
  24. data/app/controllers/iron/application_controller.rb +7 -0
  25. data/app/controllers/iron/block_definitions/field_definitions_controller.rb +8 -0
  26. data/app/controllers/iron/block_definitions_controller.rb +52 -0
  27. data/app/controllers/iron/blocks_controller.rb +12 -0
  28. data/app/controllers/iron/content_types/field_definitions_controller.rb +8 -0
  29. data/app/controllers/iron/content_types_controller.rb +52 -0
  30. data/app/controllers/iron/entries_controller.rb +103 -0
  31. data/app/controllers/iron/field_definitions_controller.rb +55 -0
  32. data/app/controllers/iron/first_runs_controller.rb +29 -0
  33. data/app/controllers/iron/home_controller.rb +6 -0
  34. data/app/controllers/iron/icons_controller.rb +10 -0
  35. data/app/controllers/iron/locales_controller.rb +52 -0
  36. data/app/controllers/iron/passwords_controller.rb +36 -0
  37. data/app/controllers/iron/references_controller.rb +11 -0
  38. data/app/controllers/iron/schemas_controller.rb +32 -0
  39. data/app/controllers/iron/sessions_controller.rb +32 -0
  40. data/app/controllers/iron/settings_controller.rb +23 -0
  41. data/app/controllers/iron/users_controller.rb +66 -0
  42. data/app/helpers/iron/application_helper.rb +25 -0
  43. data/app/helpers/iron/avatar_helper.rb +35 -0
  44. data/app/helpers/iron/block_helper.rb +4 -0
  45. data/app/helpers/iron/buttons_helper.rb +14 -0
  46. data/app/helpers/iron/components/badge_helper.rb +70 -0
  47. data/app/helpers/iron/components/dropdown_helper.rb +161 -0
  48. data/app/helpers/iron/content_types_helper.rb +11 -0
  49. data/app/helpers/iron/entries_helper.rb +13 -0
  50. data/app/helpers/iron/field_definitions_helper.rb +5 -0
  51. data/app/helpers/iron/form_builder.rb +43 -0
  52. data/app/helpers/iron/icons_helper.rb +30 -0
  53. data/app/helpers/iron/image_helper.rb +50 -0
  54. data/app/javascript/iron/application.js +6 -0
  55. data/app/javascript/iron/controllers/application.js +9 -0
  56. data/app/javascript/iron/controllers/element_controller.js +11 -0
  57. data/app/javascript/iron/controllers/file_upload_controller.js +18 -0
  58. data/app/javascript/iron/controllers/form_controller.js +7 -0
  59. data/app/javascript/iron/controllers/handle_controller.js +27 -0
  60. data/app/javascript/iron/controllers/icon_picker_controller.js +48 -0
  61. data/app/javascript/iron/controllers/index.js +4 -0
  62. data/app/javascript/iron/controllers/sortable_list_controller.js +125 -0
  63. data/app/javascript/iron/controllers/toggle_controller.js +14 -0
  64. data/app/javascript/iron/controllers/trix_controller.js +19 -0
  65. data/app/javascript/iron/lib/lexorank.js +51 -0
  66. data/app/jobs/iron/application_job.rb +4 -0
  67. data/app/jobs/iron/generate_entry_routes_job.rb +15 -0
  68. data/app/mailers/iron/application_mailer.rb +6 -0
  69. data/app/mailers/passwords_mailer.rb +6 -0
  70. data/app/models/concerns/iron/csv_serializable.rb +28 -0
  71. data/app/models/iron/account/joinable.rb +16 -0
  72. data/app/models/iron/account.rb +13 -0
  73. data/app/models/iron/application_record.rb +5 -0
  74. data/app/models/iron/block_definition/portable.rb +20 -0
  75. data/app/models/iron/block_definition.rb +10 -0
  76. data/app/models/iron/content_type/field_queryable.rb +101 -0
  77. data/app/models/iron/content_type/portable.rb +30 -0
  78. data/app/models/iron/content_type/titlable.rb +13 -0
  79. data/app/models/iron/content_type/web_publishable.rb +47 -0
  80. data/app/models/iron/content_type.rb +39 -0
  81. data/app/models/iron/content_types/collection.rb +6 -0
  82. data/app/models/iron/content_types/single.rb +9 -0
  83. data/app/models/iron/current.rb +12 -0
  84. data/app/models/iron/entry/deep_validation.rb +32 -0
  85. data/app/models/iron/entry/presentable.rb +40 -0
  86. data/app/models/iron/entry/schemable.rb +16 -0
  87. data/app/models/iron/entry/titlable.rb +35 -0
  88. data/app/models/iron/entry/web_publishable.rb +85 -0
  89. data/app/models/iron/entry.rb +16 -0
  90. data/app/models/iron/field/belongs_to_entry.rb +29 -0
  91. data/app/models/iron/field.rb +20 -0
  92. data/app/models/iron/field_definition/portable.rb +36 -0
  93. data/app/models/iron/field_definition.rb +42 -0
  94. data/app/models/iron/field_definitions/block.rb +23 -0
  95. data/app/models/iron/field_definitions/block_list.rb +8 -0
  96. data/app/models/iron/field_definitions/boolean.rb +7 -0
  97. data/app/models/iron/field_definitions/date.rb +7 -0
  98. data/app/models/iron/field_definitions/file.rb +67 -0
  99. data/app/models/iron/field_definitions/number.rb +7 -0
  100. data/app/models/iron/field_definitions/reference.rb +8 -0
  101. data/app/models/iron/field_definitions/reference_list.rb +8 -0
  102. data/app/models/iron/field_definitions/rich_text_area.rb +4 -0
  103. data/app/models/iron/field_definitions/text_area.rb +7 -0
  104. data/app/models/iron/field_definitions/text_field.rb +25 -0
  105. data/app/models/iron/fields/block.rb +40 -0
  106. data/app/models/iron/fields/block_list.rb +16 -0
  107. data/app/models/iron/fields/boolean.rb +7 -0
  108. data/app/models/iron/fields/date.rb +7 -0
  109. data/app/models/iron/fields/file.rb +29 -0
  110. data/app/models/iron/fields/number.rb +7 -0
  111. data/app/models/iron/fields/reference.rb +14 -0
  112. data/app/models/iron/fields/reference_list.rb +17 -0
  113. data/app/models/iron/fields/rich_text_area.rb +9 -0
  114. data/app/models/iron/fields/text_area.rb +7 -0
  115. data/app/models/iron/fields/text_field.rb +19 -0
  116. data/app/models/iron/first_run.rb +13 -0
  117. data/app/models/iron/icon_catalog.rb +45 -0
  118. data/app/models/iron/locale.rb +30 -0
  119. data/app/models/iron/reference.rb +8 -0
  120. data/app/models/iron/schema_archive.rb +71 -0
  121. data/app/models/iron/schema_exporter.rb +15 -0
  122. data/app/models/iron/schema_importer.rb +278 -0
  123. data/app/models/iron/session.rb +5 -0
  124. data/app/models/iron/user/role.rb +21 -0
  125. data/app/models/iron/user.rb +37 -0
  126. data/app/views/active_storage/blobs/_blob.html.erb +14 -0
  127. data/app/views/iron/block_definitions/_block_definition.html.erb +21 -0
  128. data/app/views/iron/block_definitions/_form.html.erb +46 -0
  129. data/app/views/iron/block_definitions/edit.html.erb +8 -0
  130. data/app/views/iron/block_definitions/index.html.erb +18 -0
  131. data/app/views/iron/block_definitions/new.html.erb +8 -0
  132. data/app/views/iron/block_definitions/show.html.erb +34 -0
  133. data/app/views/iron/blocks/new.turbo_stream.erb +6 -0
  134. data/app/views/iron/content_types/_content_type.html.erb +21 -0
  135. data/app/views/iron/content_types/_form.html.erb +137 -0
  136. data/app/views/iron/content_types/edit.html.erb +8 -0
  137. data/app/views/iron/content_types/index.html.erb +28 -0
  138. data/app/views/iron/content_types/new.html.erb +8 -0
  139. data/app/views/iron/content_types/show.html.erb +49 -0
  140. data/app/views/iron/entries/_entry.html.erb +17 -0
  141. data/app/views/iron/entries/_entry_option.html.erb +19 -0
  142. data/app/views/iron/entries/_form.html.erb +63 -0
  143. data/app/views/iron/entries/edit.html.erb +52 -0
  144. data/app/views/iron/entries/entry.html.erb +21 -0
  145. data/app/views/iron/entries/fields/_block.html.erb +92 -0
  146. data/app/views/iron/entries/fields/_block_list.html.erb +45 -0
  147. data/app/views/iron/entries/fields/_boolean.html.erb +13 -0
  148. data/app/views/iron/entries/fields/_date.html.erb +11 -0
  149. data/app/views/iron/entries/fields/_file.html.erb +60 -0
  150. data/app/views/iron/entries/fields/_number.html.erb +11 -0
  151. data/app/views/iron/entries/fields/_reference.html.erb +12 -0
  152. data/app/views/iron/entries/fields/_reference_item.html.erb +28 -0
  153. data/app/views/iron/entries/fields/_reference_list.html.erb +44 -0
  154. data/app/views/iron/entries/fields/_rich_text_area.html.erb +13 -0
  155. data/app/views/iron/entries/fields/_text_area.html.erb +11 -0
  156. data/app/views/iron/entries/fields/_text_field.html.erb +28 -0
  157. data/app/views/iron/entries/index.html.erb +21 -0
  158. data/app/views/iron/entries/new.html.erb +8 -0
  159. data/app/views/iron/entries/search.html.erb +33 -0
  160. data/app/views/iron/field_definitions/_field_definition.html.erb +37 -0
  161. data/app/views/iron/field_definitions/block/_form.html.erb +16 -0
  162. data/app/views/iron/field_definitions/block_list/_form.html.erb +13 -0
  163. data/app/views/iron/field_definitions/boolean/_form.html.erb +2 -0
  164. data/app/views/iron/field_definitions/date/_form.html.erb +2 -0
  165. data/app/views/iron/field_definitions/edit.html.erb +16 -0
  166. data/app/views/iron/field_definitions/file/_form.html.erb +40 -0
  167. data/app/views/iron/field_definitions/index.html.erb +14 -0
  168. data/app/views/iron/field_definitions/layouts/_form.html.erb +47 -0
  169. data/app/views/iron/field_definitions/new.html.erb +22 -0
  170. data/app/views/iron/field_definitions/number/_form.html.erb +2 -0
  171. data/app/views/iron/field_definitions/reference/_form.html.erb +13 -0
  172. data/app/views/iron/field_definitions/reference_list/_form.html.erb +13 -0
  173. data/app/views/iron/field_definitions/rich_text_area/_form.html.erb +2 -0
  174. data/app/views/iron/field_definitions/text_area/_form.html.erb +2 -0
  175. data/app/views/iron/field_definitions/text_field/_form.html.erb +17 -0
  176. data/app/views/iron/first_runs/show.html.erb +44 -0
  177. data/app/views/iron/home/show.html.erb +2 -0
  178. data/app/views/iron/icons/index.html.erb +21 -0
  179. data/app/views/iron/locales/_form.html.erb +24 -0
  180. data/app/views/iron/locales/_locale.html.erb +15 -0
  181. data/app/views/iron/locales/edit.html.erb +8 -0
  182. data/app/views/iron/locales/index.html.erb +12 -0
  183. data/app/views/iron/locales/new.html.erb +8 -0
  184. data/app/views/iron/passwords/edit.html.erb +7 -0
  185. data/app/views/iron/passwords/new.html.erb +26 -0
  186. data/app/views/iron/passwords_mailer/reset.html.erb +4 -0
  187. data/app/views/iron/passwords_mailer/reset.text.erb +2 -0
  188. data/app/views/iron/published_pages/show.html.erb +10 -0
  189. data/app/views/iron/references/new.turbo_stream.erb +6 -0
  190. data/app/views/iron/schemas/new.html.erb +57 -0
  191. data/app/views/iron/sessions/new.html.erb +36 -0
  192. data/app/views/iron/settings/show.html.erb +38 -0
  193. data/app/views/iron/shared/_icon_picker.html.erb +60 -0
  194. data/app/views/iron/users/_form.html.erb +21 -0
  195. data/app/views/iron/users/_user.html.erb +27 -0
  196. data/app/views/iron/users/edit.html.erb +12 -0
  197. data/app/views/iron/users/index.html.erb +21 -0
  198. data/app/views/iron/users/new.html.erb +29 -0
  199. data/app/views/iron/users/show.html.erb +21 -0
  200. data/app/views/layouts/action_text/contents/_content.html.erb +3 -0
  201. data/app/views/layouts/iron/_sidebar.html.erb +83 -0
  202. data/app/views/layouts/iron/_sidebar_item.html.erb +14 -0
  203. data/app/views/layouts/iron/_toast.html.erb +29 -0
  204. data/app/views/layouts/iron/application.html.erb +46 -0
  205. data/app/views/layouts/iron/authentication.html.erb +41 -0
  206. data/config/importmap.rb +9 -0
  207. data/config/routes.rb +40 -0
  208. data/db/migrate/20250422131656_initial_iron_schema.rb +105 -0
  209. data/db/migrate/20250427100754_create_iron_accounts.rb +10 -0
  210. data/db/migrate/20250427104020_add_role_to_iron_users.rb +5 -0
  211. data/db/migrate/20250428094923_add_default_iron_locale_to_iron_account.rb +5 -0
  212. data/db/migrate/20250504144731_create_join_table_content_types_field_definitions.rb +25 -0
  213. data/db/migrate/20250505090716_add_title_field_definition_to_iron_content_types.rb +5 -0
  214. data/db/migrate/20250512210352_create_iron_references.rb +11 -0
  215. data/db/migrate/20250524220355_add_referenced_entry_id_to_iron_fields.rb +5 -0
  216. data/db/migrate/20250529211703_add_metadata_to_iron_field_definitions.rb +5 -0
  217. data/db/migrate/20250529211704_add_value_boolean_to_iron_fields.rb +5 -0
  218. data/db/migrate/20250531172837_add_type_to_iron_content_types.rb +5 -0
  219. data/db/migrate/20250601080146_add_icon_to_iron_content_types.rb +5 -0
  220. data/db/migrate/20250609091605_add_web_publishing_to_iron_content_types.rb +7 -0
  221. data/db/migrate/20250609091813_add_route_to_iron_entries.rb +6 -0
  222. data/lib/generators/iron/pages/pages_generator.rb +34 -0
  223. data/lib/generators/iron/pages/templates/pages_controller.rb +14 -0
  224. data/lib/generators/iron/pages/templates/show.html.erb +9 -0
  225. data/lib/generators/iron/template/template_generator.rb +35 -0
  226. data/lib/generators/iron/template/templates/content_type.html.erb +5 -0
  227. data/lib/iron/cva.rb +69 -0
  228. data/lib/iron/engine.rb +42 -0
  229. data/lib/iron/image_analyzer.rb +26 -0
  230. data/lib/iron/lexorank/exceptions.rb +4 -0
  231. data/lib/iron/lexorank/rankable.rb +185 -0
  232. data/lib/iron/lexorank/rebalance_rank_job.rb +10 -0
  233. data/lib/iron/lexorank/utils.rb +77 -0
  234. data/lib/iron/lexorank.rb +7 -0
  235. data/lib/iron/routing.rb +66 -0
  236. data/lib/iron/sdk.rb +19 -0
  237. data/lib/iron/version.rb +3 -0
  238. data/lib/iron.rb +8 -0
  239. data/lib/puma/plugin/iron_tailwindcss.rb +78 -0
  240. metadata +458 -0
@@ -0,0 +1,101 @@
1
+ module Iron
2
+ module ContentType::FieldQueryable
3
+ def order_by_field(field_handle, direction = :asc)
4
+ content_type = proxy_association.owner
5
+ definition = content_type.field_definitions.find_by!(handle: field_handle)
6
+
7
+ table_alias = "order_field_for_#{field_handle}"
8
+ scope = joins("LEFT OUTER JOIN iron_fields AS #{table_alias} ON #{table_alias}.entry_id = iron_entries.id AND #{table_alias}.definition_id = #{definition.id}")
9
+ scope.order("#{table_alias}.#{definition.value_column}": direction)
10
+ end
11
+
12
+ def where_fields(conditions)
13
+ content_type = proxy_association.owner
14
+ scope = self
15
+ conditions.each do |handle, condition|
16
+ definition = content_type.field_definitions.find_by! handle: handle
17
+ field_scope = joins(:fields).where(iron_fields: { definition: })
18
+ field_scope = case condition
19
+ when Hash
20
+ apply_field_conditions(field_scope, definition.value_column, condition)
21
+ when Range
22
+ field_scope.where("iron_fields.#{definition.value_column} BETWEEN ? AND ?", condition)
23
+ when Array
24
+ field_scope.where("iron_fields.#{definition.value_column} IN (?)", condition)
25
+ else
26
+ field_scope.where("iron_fields.#{definition.value_column} = ?", condition)
27
+ end
28
+
29
+ scope = scope.merge(field_scope)
30
+ end
31
+
32
+ scope.distinct
33
+ end
34
+
35
+ def find_by_fields(conditions)
36
+ where_fields(conditions).first
37
+ end
38
+
39
+ def find_by_fields!(conditions)
40
+ record = find_by_fields(conditions)
41
+ raise ActiveRecord::RecordNotFound unless record.present?
42
+ record
43
+ end
44
+
45
+ def pluck_field(field_handle, distinct: false)
46
+ content_type = proxy_association.owner
47
+ definition = content_type.field_definitions.find_by! handle: field_handle
48
+
49
+ scope = definition.fields
50
+ scope = scope.distinct if distinct
51
+ scope.pluck(definition.value_column)
52
+ end
53
+
54
+ private
55
+
56
+ def apply_field_conditions(scope, column, conditions)
57
+ conditions.each do |op, value|
58
+ scope = case op.to_sym
59
+ when :eq
60
+ scope.where("iron_fields.#{column} = ?", value)
61
+ when :ne, :not
62
+ scope.where("iron_fields.#{column} != ?", value)
63
+ when :gt
64
+ scope.where("iron_fields.#{column} > ?", value)
65
+ when :gte
66
+ scope.where("iron_fields.#{column} >= ?", value)
67
+ when :lt
68
+ scope.where("iron_fields.#{column} < ?", value)
69
+ when :lte
70
+ scope.where("iron_fields.#{column} <= ?", value)
71
+ when :like
72
+ scope.where("iron_fields.#{column} LIKE ?", "%#{value}%")
73
+ when :not_like
74
+ scope.where("iron_fields.#{column} NOT LIKE ?", "%#{value}%")
75
+ when :starts_with
76
+ scope.where("iron_fields.#{column} LIKE ?", "#{value}%")
77
+ when :ends_with
78
+ scope.where("iron_fields.#{column} LIKE ?", "%#{value}")
79
+ when :in
80
+ scope.where("iron_fields.#{column} IN (?)", value)
81
+ when :not_in
82
+ scope.where("iron_fields.#{column} NOT IN (?)", value)
83
+ when :blank
84
+ if value
85
+ scope.where("iron_fields.#{column} IS NULL OR iron_fields.#{column} = ''")
86
+ else
87
+ scope.where("iron_fields.#{column} IS NOT NULL AND iron_fields.#{column} != ''")
88
+ end
89
+ when :present
90
+ if value
91
+ scope.where("iron_fields.#{column} IS NOT NULL AND iron_fields.#{column} != ''")
92
+ else
93
+ scope.where("iron_fields.#{column} IS NULL OR iron_fields.#{column} = ''")
94
+ end
95
+ else
96
+ raise ArgumentError, "Unknown operator: #{op}"
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,30 @@
1
+ module Iron
2
+ module ContentType::Portable
3
+ extend ActiveSupport::Concern
4
+ include ::Iron::CsvSerializable
5
+
6
+ class_methods do
7
+ def csv_headers
8
+ %w[handle name type description icon title_field_handle web_publishing_enabled web_page_title_field_handle base_path]
9
+ end
10
+
11
+ def csv_scope
12
+ includes(:title_field_definition, :web_page_title_field_definition)
13
+ end
14
+ end
15
+
16
+ def to_csv_row
17
+ [
18
+ handle,
19
+ name,
20
+ type.demodulize.underscore,
21
+ description,
22
+ icon,
23
+ title_field_definition&.handle,
24
+ web_publishing_enabled,
25
+ web_page_title_field_definition&.handle,
26
+ base_path
27
+ ]
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,13 @@
1
+ module Iron
2
+ module ContentType::Titlable
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ belongs_to :title_field_definition, class_name: "Iron::FieldDefinition", optional: true
7
+ end
8
+
9
+ def titlable_definitions
10
+ field_definitions.where(type: [ Iron::FieldDefinitions::TextField ])
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,47 @@
1
+ module Iron
2
+ module ContentType::WebPublishable
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ belongs_to :web_page_title_field_definition, class_name: "FieldDefinition", optional: true
7
+
8
+ validates :base_path, format: { with: /\A[a-z0-9\-\/]*\z/, message: "can only contain lowercase letters, numbers, hyphens and slashes" }, allow_blank: true
9
+
10
+ scope :web_published, -> { where(web_publishing_enabled: true) }
11
+
12
+ after_save :generate_entries_routes, if: -> { saved_change_to_web_publishing_enabled? && web_publishing_enabled? }
13
+ after_save :reload_routes!, if: :should_reload_routes?
14
+ after_destroy :reload_routes!, if: :web_publishing_enabled?
15
+ end
16
+
17
+ def path_for(entry)
18
+ return nil unless web_publishing_enabled?
19
+
20
+ path_parts = []
21
+ path_parts << base_path if base_path.present?
22
+ path_parts << entry.route
23
+
24
+ "/" + path_parts.compact.join("/")
25
+ end
26
+
27
+ def index_entry
28
+ entries.find_by(route: "")
29
+ end
30
+
31
+ private
32
+
33
+ def generate_entries_routes
34
+ GenerateEntryRoutesJob.perform_later(self)
35
+ end
36
+
37
+ def should_reload_routes?
38
+ saved_change_to_web_publishing_enabled? ||
39
+ saved_change_to_base_path? ||
40
+ (web_publishing_enabled? && saved_change_to_handle?)
41
+ end
42
+
43
+ def reload_routes!
44
+ Rails.application.reload_routes!
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,39 @@
1
+ module Iron
2
+ class ContentType < ApplicationRecord
3
+ include Titlable, WebPublishable, Portable
4
+
5
+ TYPES = %w[Single Collection].freeze
6
+
7
+ validates :name, presence: true
8
+ validates :handle, presence: true
9
+
10
+ has_many :field_definitions, -> { ranked }, as: :schemable, dependent: :destroy
11
+
12
+ has_many :entries, -> { extending FieldQueryable }, dependent: :destroy
13
+
14
+ class << self
15
+ def classify_type(type)
16
+ "Iron::ContentTypes::#{type}"
17
+ end
18
+
19
+ def inherited(subclass)
20
+ super
21
+ def subclass.model_name
22
+ superclass.model_name
23
+ end
24
+ end
25
+ end
26
+
27
+ def humanized_type
28
+ type.demodulize
29
+ end
30
+
31
+ def single?
32
+ is_a?(ContentTypes::Single)
33
+ end
34
+
35
+ def collection?
36
+ is_a?(ContentTypes::Collection)
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,6 @@
1
+ module Iron
2
+ module ContentTypes
3
+ class Collection < ContentType
4
+ end
5
+ end
6
+ end
@@ -0,0 +1,9 @@
1
+ module Iron
2
+ module ContentTypes
3
+ class Single < ContentType
4
+ def entry
5
+ entries.first || entries.build
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,12 @@
1
+ module Iron
2
+ class Current < ActiveSupport::CurrentAttributes
3
+ attribute :session
4
+ delegate :user, to: :session, allow_nil: true
5
+
6
+ attribute :locale
7
+
8
+ def account
9
+ Account.first
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,32 @@
1
+ # ActiveStorage file attachments in deeply nested associations don't properly prevent parent records
2
+ # from saving when validation fails. This is a Rails quirk where attachment validations don't bubble
3
+ # up through nested attributes. This concern recursively checks all nested fields for validation
4
+ # errors and prevents the Entry from saving if any file validations fail.
5
+
6
+ module Iron
7
+ class Entry
8
+ module DeepValidation
9
+ extend ActiveSupport::Concern
10
+
11
+ included do
12
+ validate :all_fields_are_valid
13
+ end
14
+
15
+ private
16
+
17
+ def all_fields_are_valid
18
+ any_invalid = false
19
+ walk = lambda do |field|
20
+ any_invalid ||= field.invalid?
21
+ if field.is_a?(Iron::Fields::BlockList)
22
+ field.blocks.each { |b| b.fields.each { |f| walk.call(f) } }
23
+ elsif field.is_a?(Iron::Fields::Block)
24
+ field.fields.each { |f| walk.call(f) }
25
+ end
26
+ end
27
+ fields.select { |f| f.parent.nil? }.each { |f| walk.call(f) }
28
+ errors.add(:base, "One or more fields are invalid") if any_invalid
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,40 @@
1
+ module Iron
2
+ module Entry::Presentable
3
+ extend ActiveSupport::Concern
4
+
5
+ def attributes
6
+ locale = Iron::Current.locale || Iron::Locale.default
7
+
8
+ Rails.cache.fetch("#{cache_key_with_version}/#{locale.code}/attributes") do
9
+ result = OpenStruct.new(id: id)
10
+
11
+ result._metadata = OpenStruct.new(
12
+ locale: locale,
13
+ web: OpenStruct.new(
14
+ title: web_title,
15
+ path: path
16
+ )
17
+ )
18
+
19
+ content_type.field_definitions.each do |field_definition|
20
+ field = find_field_for_presentation(field_definition, locale)
21
+ result[field_definition.handle] = field&.value
22
+ end
23
+
24
+ result
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+
31
+ def find_field_for_presentation(field_definition, locale)
32
+ fields.find do |f|
33
+ f.definition_id == field_definition.id &&
34
+ f.definition_type == field_definition.class.base_class.to_s &&
35
+ f.locale_id == locale.id &&
36
+ f.parent.nil?
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,16 @@
1
+ module Iron
2
+ module Entry::Schemable
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ belongs_to :content_type
7
+
8
+ scope :with_content_type, ->(types) {
9
+ ids = Array(types).map do |ct|
10
+ ct.is_a?(Iron::ContentType) ? ct.id : ct
11
+ end
12
+ where(content_type_id: ids)
13
+ }
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,35 @@
1
+ module Iron
2
+ module Entry::Titlable
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ scope :search_by_title, ->(query, locale = nil) {
7
+ locale ||= Iron::Locale.default
8
+
9
+ joins(:fields)
10
+ .joins(:content_type)
11
+ .where("iron_content_types.title_field_definition_id = iron_fields.definition_id")
12
+ .where("iron_fields.locale_id = ?", locale.id)
13
+ .where("iron_fields.value_string LIKE ?", "%#{query}%")
14
+ }
15
+ end
16
+
17
+ def title
18
+ return default_title if title_field.blank?
19
+
20
+ title_field.value.presence || default_title
21
+ end
22
+
23
+ private
24
+
25
+ def title_field
26
+ return nil unless content_type.title_field_definition.present?
27
+
28
+ @title_field ||= fields.find_by(definition: content_type.title_field_definition)
29
+ end
30
+
31
+ def default_title
32
+ "#{content_type.name} #{id}"
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,85 @@
1
+ module Iron
2
+ module Entry::WebPublishable
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ before_validation :generate_route, if: :should_generate_route?
7
+ before_validation :normalize_route
8
+
9
+ validates :route, uniqueness: { scope: :content_type_id, allow_blank: true }, if: :route_required?
10
+ validates :route, format: { with: /\A[a-z0-9\-\/]*\z/, message: "can only contain lowercase letters, numbers, hyphens and slashes" }, allow_blank: true
11
+ validate :index_route_uniqueness, if: :use_as_index
12
+ end
13
+
14
+ def use_as_index=(value)
15
+ if ActiveModel::Type::Boolean.new.cast(value)
16
+ self.route = ""
17
+ elsif route.blank?
18
+ self.route = nil
19
+ end
20
+ end
21
+
22
+ def use_as_index
23
+ route == ""
24
+ end
25
+
26
+ def web_title
27
+ return nil unless content_type.web_page_title_field_definition
28
+
29
+ title_field = fields.find_by(
30
+ definition: content_type.web_page_title_field_definition,
31
+ locale: Iron::Locale.default
32
+ ) || fields.detect { |f|
33
+ f.definition_id == content_type.web_page_title_field_definition_id &&
34
+ f.locale == Iron::Locale.default
35
+ }
36
+
37
+ title_field&.value
38
+ end
39
+
40
+ def path
41
+ content_type.path_for(self) if content_type.web_publishing_enabled?
42
+ end
43
+
44
+ private
45
+
46
+ def should_generate_route?
47
+ content_type.web_publishing_enabled? && route.nil?
48
+ end
49
+
50
+ def route_required?
51
+ content_type.web_publishing_enabled?
52
+ end
53
+
54
+ def generate_route
55
+ return unless web_title.present?
56
+
57
+ base_handle = web_title.parameterize
58
+ handle = base_handle
59
+ counter = 1
60
+
61
+ while Entry.where(content_type: content_type, route: handle).where.not(id: id).exists?
62
+ handle = "#{base_handle}-#{counter}"
63
+ counter += 1
64
+ end
65
+
66
+ self.route = handle
67
+ end
68
+
69
+ def normalize_route
70
+ return if route.blank?
71
+
72
+ self.route = route.downcase
73
+ .gsub(/[^a-z0-9\-\/]+/, "-") # Replace non-allowed chars with hyphens
74
+ .gsub(/\-+/, "-") # Replace multiple hyphens with single hyphen
75
+ .gsub(/\A[\-\/]+|[\-\/]+\z/, "") # Remove leading/trailing hyphens and slashes
76
+ end
77
+
78
+
79
+ def index_route_uniqueness
80
+ if content_type.index_entry && content_type.index_entry.id != id
81
+ errors.add(:use_as_index, "is already taken - another entry is already set as the index page")
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,16 @@
1
+ module Iron
2
+ class Entry < ApplicationRecord
3
+ include Titlable, Schemable, WebPublishable, Presentable, DeepValidation
4
+
5
+ belongs_to :creator, class_name: "Iron::User", default: -> { Current.user }
6
+ has_many :fields, inverse_of: :entry, dependent: :destroy
7
+
8
+ accepts_nested_attributes_for :fields, allow_destroy: true
9
+
10
+ def find_or_build_field(definition, locale)
11
+ field = fields.find { |f| f.definition == definition && f.locale == locale } ||
12
+ fields.build(type: definition.field_type, definition:, locale:)
13
+ field
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,29 @@
1
+ module Iron
2
+ module Field::BelongsToEntry
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ after_save :ensure_entry
7
+
8
+ belongs_to :entry, inverse_of: :fields, optional: true, touch: true
9
+ end
10
+
11
+ private
12
+
13
+ def ensure_entry
14
+ return if entry_id.present?
15
+
16
+ current_parent = parent
17
+
18
+ while current_parent.present?
19
+ if current_parent.entry_id.present?
20
+ update_column(:entry_id, current_parent.entry_id)
21
+ break
22
+ end
23
+ current_parent = current_parent.parent
24
+ end
25
+
26
+ raise "Field must be bound to an entry" unless entry_id.present?
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,20 @@
1
+ module Iron
2
+ class Field < ApplicationRecord
3
+ include BelongsToEntry
4
+
5
+ TYPES = %w[text_field text_area rich_text_area number file boolean date block block_list reference_list reference].freeze
6
+
7
+ belongs_to :definition, polymorphic: true
8
+ belongs_to :locale
9
+ belongs_to :parent, polymorphic: true, optional: true, touch: true
10
+
11
+
12
+ def type_handle
13
+ type.demodulize.underscore
14
+ end
15
+
16
+ def value
17
+ raise "Field type '#{type}' value method not supported"
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,36 @@
1
+ module Iron
2
+ module FieldDefinition::Portable
3
+ extend ActiveSupport::Concern
4
+ include ::Iron::CsvSerializable
5
+
6
+ class_methods do
7
+ def csv_headers
8
+ %w[parent_type parent_handle handle name type rank metadata supported_block_definitions]
9
+ end
10
+
11
+ def csv_scope
12
+ includes(:schemable)
13
+ end
14
+ end
15
+
16
+ def to_csv_row
17
+ [
18
+ schemable_type == "Iron::ContentType" ? "content_type" : "block",
19
+ schemable.handle,
20
+ handle,
21
+ name,
22
+ type.demodulize.underscore,
23
+ rank,
24
+ metadata.to_json,
25
+ export_supported_block_definitions
26
+ ]
27
+ end
28
+
29
+ private
30
+
31
+ def export_supported_block_definitions
32
+ return nil unless respond_to?(:supported_block_definitions)
33
+ supported_block_definitions.pluck(:handle).join("|")
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,42 @@
1
+ module Iron
2
+ class FieldDefinition < ApplicationRecord
3
+ include Portable
4
+
5
+ TYPES = %w[text_field text_area rich_text_area number file boolean date block block_list reference_list reference].freeze
6
+
7
+ belongs_to :schemable, polymorphic: true
8
+
9
+ has_rank scoped_by: :schemable
10
+ before_create :move_to_bottom
11
+
12
+ has_many :fields, as: :definition, inverse_of: :definition, dependent: :destroy
13
+
14
+ has_one :titlable_content_type, class_name: "Iron::ContentType", inverse_of: :title_field_definition, dependent: :nullify
15
+ has_one :web_publishable_content_type, class_name: "Iron::ContentType", foreign_key: :web_page_title_field_definition_id, inverse_of: :web_page_title_field_definition, dependent: :nullify
16
+
17
+ validates :name, presence: true
18
+ validates :handle, presence: true
19
+
20
+ class << self
21
+ def classify_type(type)
22
+ "Iron::FieldDefinitions::#{type.classify}"
23
+ end
24
+ end
25
+
26
+ def humanized_type
27
+ type.demodulize.humanize
28
+ end
29
+
30
+ def type_handle
31
+ type.demodulize.underscore
32
+ end
33
+
34
+ def field_type
35
+ "Iron::Fields::#{type.demodulize}"
36
+ end
37
+
38
+ def value_column
39
+ raise "value_column not implemented by '#{type}'"
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,23 @@
1
+ module Iron
2
+ class FieldDefinitions::Block < FieldDefinition
3
+ has_and_belongs_to_many :supported_block_definitions,
4
+ class_name: "::Iron::BlockDefinition",
5
+ foreign_key: "field_definition_id",
6
+ association_foreign_key: "block_definition_id"
7
+
8
+ validate :validate_exactly_one_supported_block_definition
9
+
10
+ def supported_block_definition
11
+ supported_block_definitions.first
12
+ end
13
+
14
+ def supported_block_definition_id
15
+ supported_block_definition&.id
16
+ end
17
+
18
+ private
19
+ def validate_exactly_one_supported_block_definition
20
+ errors.add(:supported_block_definitions, "must have exactly one supported block definition") unless supported_block_definitions.size == 1
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,8 @@
1
+ module Iron
2
+ class FieldDefinitions::BlockList < FieldDefinition
3
+ has_and_belongs_to_many :supported_block_definitions,
4
+ class_name: "::Iron::BlockDefinition",
5
+ foreign_key: "field_definition_id",
6
+ association_foreign_key: "block_definition_id"
7
+ end
8
+ end
@@ -0,0 +1,7 @@
1
+ module Iron
2
+ class FieldDefinitions::Boolean < FieldDefinition
3
+ def value_column
4
+ :value_boolean
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ module Iron
2
+ class FieldDefinitions::Date < FieldDefinition
3
+ def value_column
4
+ :value_datetime
5
+ end
6
+ end
7
+ end