iron-cms 0.17.2 → 0.18.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 (142) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +88 -4
  3. data/app/assets/builds/iron.css +255 -106
  4. data/app/assets/tailwind/iron/application.css +1 -0
  5. data/app/assets/tailwind/iron/components/file-upload.css +26 -0
  6. data/app/assets/tailwind/iron/lexxy.css +111 -87
  7. data/app/controllers/concerns/iron/schema_editing.rb +19 -0
  8. data/app/controllers/iron/api/schema/base_controller.rb +25 -0
  9. data/app/controllers/iron/api/schema/block_definitions_controller.rb +49 -0
  10. data/app/controllers/iron/api/schema/content_types_controller.rb +64 -0
  11. data/app/controllers/iron/api/schema/field_definitions_controller.rb +88 -0
  12. data/app/controllers/iron/api/schema/locales_controller.rb +52 -0
  13. data/app/controllers/iron/application_controller.rb +1 -1
  14. data/app/controllers/iron/block_definitions_controller.rb +1 -0
  15. data/app/controllers/iron/content_types_controller.rb +1 -0
  16. data/app/controllers/iron/field_definitions_controller.rb +2 -1
  17. data/app/controllers/iron/first_runs_controller.rb +1 -1
  18. data/app/controllers/iron/locales_controller.rb +1 -0
  19. data/app/controllers/iron/sessions_controller.rb +1 -1
  20. data/app/helpers/iron/avatar_helper.rb +24 -2
  21. data/app/javascript/iron/controllers/file_upload_controller.js +38 -7
  22. data/app/models/iron/account.rb +1 -1
  23. data/app/models/iron/api/openapi_spec.rb +37 -15
  24. data/app/models/iron/block_definition/exportable.rb +1 -1
  25. data/app/models/iron/block_definition.rb +3 -1
  26. data/app/models/iron/content.rb +176 -0
  27. data/app/models/iron/content_type/exportable.rb +3 -1
  28. data/app/models/iron/content_type/importable.rb +1 -1
  29. data/app/models/iron/content_type.rb +6 -1
  30. data/app/models/iron/entry/content_assignable.rb +10 -2
  31. data/app/models/iron/exporter.rb +1 -26
  32. data/app/models/iron/field/length_constrained.rb +17 -0
  33. data/app/models/iron/field/validatable.rb +16 -0
  34. data/app/models/iron/field.rb +1 -1
  35. data/app/models/iron/field_definition/exportable.rb +3 -4
  36. data/app/models/iron/field_definition/importable.rb +16 -9
  37. data/app/models/iron/field_definition/ranked.rb +1 -1
  38. data/app/models/iron/field_definition/searchable.rb +2 -0
  39. data/app/models/iron/field_definition/validatable.rb +40 -0
  40. data/app/models/iron/field_definition/validations.rb +175 -0
  41. data/app/models/iron/field_definition.rb +1 -1
  42. data/app/models/iron/field_definitions/date.rb +2 -0
  43. data/app/models/iron/field_definitions/file.rb +3 -11
  44. data/app/models/iron/field_definitions/number.rb +2 -0
  45. data/app/models/iron/field_definitions/reference.rb +2 -0
  46. data/app/models/iron/field_definitions/rich_text_area.rb +1 -0
  47. data/app/models/iron/field_definitions/text_area.rb +2 -0
  48. data/app/models/iron/field_definitions/text_field.rb +1 -9
  49. data/app/models/iron/fields/block.rb +5 -1
  50. data/app/models/iron/fields/block_list.rb +5 -1
  51. data/app/models/iron/fields/date.rb +19 -2
  52. data/app/models/iron/fields/file.rb +5 -3
  53. data/app/models/iron/fields/number.rb +28 -1
  54. data/app/models/iron/fields/reference.rb +2 -0
  55. data/app/models/iron/fields/rich_text_area.rb +2 -0
  56. data/app/models/iron/fields/text_area.rb +4 -0
  57. data/app/models/iron/fields/text_field.rb +9 -5
  58. data/app/models/iron/importer.rb +1 -54
  59. data/app/models/iron/locale.rb +2 -0
  60. data/app/models/iron/schema/auto_dumpable.rb +26 -0
  61. data/app/models/iron/schema/diff.rb +194 -0
  62. data/app/models/iron/schema/validation.rb +214 -0
  63. data/app/models/iron/schema.rb +282 -0
  64. data/app/models/iron/seed.rb +5 -3
  65. data/app/models/iron/system.rb +7 -0
  66. data/app/models/iron/user/deactivatable.rb +5 -0
  67. data/app/models/iron/user.rb +18 -1
  68. data/app/views/iron/api/fields/_rich_text_area.json.jbuilder +2 -2
  69. data/app/views/iron/api/schema/block_definitions/_block_definition.json.jbuilder +4 -0
  70. data/app/views/iron/api/schema/block_definitions/index.json.jbuilder +1 -0
  71. data/app/views/iron/api/schema/block_definitions/show.json.jbuilder +1 -0
  72. data/app/views/iron/api/schema/content_types/_content_type.json.jbuilder +5 -0
  73. data/app/views/iron/api/schema/content_types/index.json.jbuilder +1 -0
  74. data/app/views/iron/api/schema/content_types/show.json.jbuilder +1 -0
  75. data/app/views/iron/api/schema/field_definitions/_field_definition.json.jbuilder +7 -0
  76. data/app/views/iron/api/schema/field_definitions/show.json.jbuilder +1 -0
  77. data/app/views/iron/api/schema/locales/_locale.json.jbuilder +4 -0
  78. data/app/views/iron/api/schema/locales/index.json.jbuilder +1 -0
  79. data/app/views/iron/api/schema/locales/show.json.jbuilder +1 -0
  80. data/app/views/iron/block_definitions/_empty_state.html.erb +5 -3
  81. data/app/views/iron/block_definitions/_form.html.erb +3 -1
  82. data/app/views/iron/block_definitions/edit.html.erb +19 -17
  83. data/app/views/iron/block_definitions/index.html.erb +7 -3
  84. data/app/views/iron/block_definitions/show.html.erb +14 -8
  85. data/app/views/iron/content_types/_content_type.html.erb +1 -1
  86. data/app/views/iron/content_types/_empty_state.html.erb +5 -3
  87. data/app/views/iron/content_types/_form.html.erb +12 -8
  88. data/app/views/iron/content_types/edit.html.erb +19 -17
  89. data/app/views/iron/content_types/index.html.erb +7 -3
  90. data/app/views/iron/content_types/show.html.erb +14 -8
  91. data/app/views/iron/entries/_empty_state.html.erb +1 -1
  92. data/app/views/iron/entries/edit.html.erb +1 -1
  93. data/app/views/iron/entries/entry.html.erb +1 -1
  94. data/app/views/iron/entries/fields/_block.html.erb +4 -0
  95. data/app/views/iron/entries/fields/_block_list.html.erb +2 -0
  96. data/app/views/iron/entries/fields/_boolean.html.erb +1 -0
  97. data/app/views/iron/entries/fields/_date.html.erb +3 -2
  98. data/app/views/iron/entries/fields/_field_errors.html.erb +7 -0
  99. data/app/views/iron/entries/fields/_field_label.html.erb +8 -0
  100. data/app/views/iron/entries/fields/_file.html.erb +11 -19
  101. data/app/views/iron/entries/fields/_number.html.erb +6 -2
  102. data/app/views/iron/entries/fields/_reference.html.erb +2 -1
  103. data/app/views/iron/entries/fields/_reference_list.html.erb +2 -0
  104. data/app/views/iron/entries/fields/_rich_text_area.html.erb +2 -1
  105. data/app/views/iron/entries/fields/_text_area.html.erb +6 -2
  106. data/app/views/iron/entries/fields/_text_field.html.erb +5 -12
  107. data/app/views/iron/field_definitions/_field_definition.html.erb +51 -29
  108. data/app/views/iron/field_definitions/date/_form.html.erb +5 -1
  109. data/app/views/iron/field_definitions/edit.html.erb +10 -8
  110. data/app/views/iron/field_definitions/file/_form.html.erb +6 -0
  111. data/app/views/iron/field_definitions/new.html.erb +5 -3
  112. data/app/views/iron/field_definitions/number/_form.html.erb +23 -1
  113. data/app/views/iron/field_definitions/reference/_form.html.erb +5 -0
  114. data/app/views/iron/field_definitions/rich_text_area/_form.html.erb +5 -0
  115. data/app/views/iron/field_definitions/text_area/_form.html.erb +23 -0
  116. data/app/views/iron/field_definitions/text_field/_form.html.erb +20 -1
  117. data/app/views/iron/home/_content_types.html.erb +1 -1
  118. data/app/views/iron/locales/_form.html.erb +5 -3
  119. data/app/views/iron/locales/edit.html.erb +1 -1
  120. data/app/views/iron/locales/index.html.erb +12 -6
  121. data/app/views/iron/shared/_schema_lock_badge.html.erb +19 -0
  122. data/config/locales/en.yml +13 -1
  123. data/config/locales/it.yml +18 -1
  124. data/config/routes.rb +11 -0
  125. data/db/migrate/20260612131538_create_iron_systems.rb +9 -0
  126. data/exe/iron +5 -0
  127. data/lib/generators/iron/agents/agents_generator.rb +52 -0
  128. data/lib/generators/iron/agents/templates/AGENTS.md +24 -0
  129. data/lib/generators/iron/agents/templates/SKILL.md +423 -0
  130. data/lib/generators/iron/install/install_generator.rb +118 -0
  131. data/lib/generators/iron/install/templates/iron_release.rake +5 -0
  132. data/lib/generators/iron/install/templates/schema.json +12 -0
  133. data/lib/generators/iron/install/templates/seeds.rb +13 -0
  134. data/lib/generators/iron/pages/pages_generator.rb +5 -0
  135. data/lib/generators/iron/pages/templates/pages_controller.rb +1 -1
  136. data/lib/generators/iron/pages/templates/show.html.erb +1 -1
  137. data/lib/install/template.rb +9 -0
  138. data/lib/iron/cli.rb +43 -0
  139. data/lib/iron/version.rb +1 -1
  140. data/lib/tasks/iron_content.rake +82 -0
  141. data/lib/tasks/iron_schema.rake +45 -0
  142. metadata +62 -3
@@ -0,0 +1,194 @@
1
+ module Iron
2
+ class Schema::Diff
3
+ PRUNE_HINT = "[in database, not in file — PRUNE=1 deletes]"
4
+ SET_ATTRIBUTES = %i[supported_block_definitions supported_content_types].freeze
5
+
6
+ attr_reader :notes
7
+
8
+ def initialize(schema)
9
+ @schema = schema
10
+ @notes = []
11
+ snapshot
12
+ end
13
+
14
+ def to_h
15
+ {
16
+ locales: @locales,
17
+ block_definitions: @block_definitions,
18
+ content_types: @content_types,
19
+ notes: notes
20
+ }
21
+ end
22
+
23
+ def empty?
24
+ sections.all? { |section| section.values.all?(&:empty?) }
25
+ end
26
+
27
+ # Removals are only applied in prune mode, so the apply task excludes them
28
+ # from its applied-changes count while still listing them as candidates.
29
+ def size(include_removals: true)
30
+ buckets = include_removals ? %i[add update remove] : %i[add update]
31
+ sections.sum { |section| section.values_at(*buckets).sum(&:size) }
32
+ end
33
+
34
+ def removals
35
+ {
36
+ locales: @locales[:remove],
37
+ block_definitions: @block_definitions[:remove],
38
+ content_types: @content_types[:remove],
39
+ block_definition_fields: field_removals(@block_definitions),
40
+ content_type_fields: field_removals(@content_types)
41
+ }
42
+ end
43
+
44
+ def add_note(note)
45
+ notes << note
46
+ end
47
+
48
+ def to_s
49
+ return "Schema is in sync." if empty?
50
+
51
+ lines = []
52
+ lines.concat(section_lines("locales", @locales))
53
+ lines.concat(section_lines("block_definitions", @block_definitions))
54
+ lines.concat(section_lines("content_types", @content_types))
55
+ lines.concat(notes.map { |note| "note: #{note}" })
56
+ lines.join("\n")
57
+ end
58
+
59
+ private
60
+
61
+ attr_reader :schema
62
+
63
+ def sections
64
+ [ @locales, @block_definitions, @content_types ]
65
+ end
66
+
67
+ # The diff is a snapshot: apply builds it before importing so it reports
68
+ # what is about to change rather than the post-import state.
69
+ def snapshot
70
+ @locales = diff_locales
71
+ @block_definitions = diff_schemables(schema[:block_definitions] || [], BlockDefinition.order(:handle))
72
+ @content_types = diff_schemables(schema[:content_types] || [], ContentType.order(:handle))
73
+ end
74
+
75
+ def diff_locales
76
+ file_locales = schema[:locales] || []
77
+ file_codes = file_locales.map { |attrs| attrs[:code].to_s }
78
+ db_by_code = Locale.order(:code).index_by(&:code)
79
+
80
+ {
81
+ add: file_codes - db_by_code.keys,
82
+ update: file_locales.filter_map { |attrs| renamed_locale_code(attrs, db_by_code[attrs[:code].to_s]) },
83
+ remove: db_by_code.keys - file_codes
84
+ }
85
+ end
86
+
87
+ def renamed_locale_code(attrs, locale)
88
+ attrs[:code].to_s if locale && attrs[:name].present? && attrs[:name].to_s != locale.name
89
+ end
90
+
91
+ def diff_schemables(file_list, db_scope)
92
+ db_by_handle = db_scope.index_by(&:handle)
93
+ file_handles = file_list.map { |attrs| attrs[:handle].to_s }
94
+
95
+ {
96
+ add: file_handles - db_by_handle.keys,
97
+ update: file_list.filter_map { |attrs| schemable_update(attrs, db_by_handle[attrs[:handle].to_s]) },
98
+ remove: db_by_handle.keys - file_handles
99
+ }
100
+ end
101
+
102
+ def schemable_update(attrs, record)
103
+ return unless record
104
+
105
+ changed = changed_attribute_names(attrs, record.export_attributes)
106
+ fields = diff_field_definitions(attrs[:field_definitions] || [], record)
107
+ reorder = reorder?(attrs[:field_definitions] || [], record)
108
+ return if changed.empty? && fields.values.all?(&:empty?) && !reorder
109
+
110
+ { handle: record.handle, changed:, fields:, reorder: }
111
+ end
112
+
113
+ def diff_field_definitions(file_fields, record)
114
+ db_by_handle = record.field_definitions.index_by(&:handle)
115
+ file_handles = file_fields.map { |attrs| attrs[:handle].to_s }
116
+
117
+ {
118
+ add: file_fields.reject { |attrs| db_by_handle.key?(attrs[:handle].to_s) }
119
+ .map { |attrs| { handle: attrs[:handle].to_s, type: attrs[:type].to_s } },
120
+ update: file_fields.filter_map { |attrs| field_update(attrs, db_by_handle[attrs[:handle].to_s]) },
121
+ remove: db_by_handle.keys - file_handles
122
+ }
123
+ end
124
+
125
+ def field_update(attrs, definition)
126
+ return unless definition
127
+
128
+ changed = changed_attribute_names(attrs, definition.export_attributes)
129
+ { handle: definition.handle, changed: } if changed.any?
130
+ end
131
+
132
+ def reorder?(file_fields, record)
133
+ file_handles = file_fields.map { |attrs| attrs[:handle].to_s }
134
+ db_handles = record.field_definitions.map(&:handle)
135
+
136
+ (db_handles & file_handles) != (file_handles & db_handles)
137
+ end
138
+
139
+ def changed_attribute_names(file_attrs, db_attrs)
140
+ file_side = normalize(file_attrs)
141
+ db_side = normalize(db_attrs)
142
+
143
+ (file_side.keys | db_side.keys).select { |key| file_side[key] != db_side[key] }
144
+ end
145
+
146
+ # Schema files omit ranks (array order rules) and false booleans, so both
147
+ # sides are compacted the same way before comparison. Supported handles
148
+ # are sets: the import cannot honor their order.
149
+ def normalize(attrs)
150
+ attrs
151
+ .deep_symbolize_keys
152
+ .except(:rank, :field_definitions)
153
+ .to_h { |key, value| [ key, normalize_value(key, value) ] }
154
+ .compact_blank
155
+ end
156
+
157
+ def normalize_value(key, value)
158
+ case value
159
+ when Hash then value.compact_blank
160
+ when Array then SET_ATTRIBUTES.include?(key) ? value.sort : value
161
+ else value
162
+ end
163
+ end
164
+
165
+ def field_removals(section)
166
+ section[:update].each_with_object({}) do |entry, removals|
167
+ removals[entry[:handle]] = entry[:fields][:remove] if entry[:fields][:remove].any?
168
+ end
169
+ end
170
+
171
+ def section_lines(title, section)
172
+ return [] if section.values.all?(&:empty?)
173
+
174
+ [ "#{title}:" ].tap do |lines|
175
+ section[:add].each { |handle| lines << " + #{handle}" }
176
+ section[:update].each { |entry| lines.concat(entry.is_a?(Hash) ? update_lines(entry) : [ " ~ #{entry}" ]) }
177
+ section[:remove].each { |handle| lines << " - #{handle} #{PRUNE_HINT}" }
178
+ end
179
+ end
180
+
181
+ def update_lines(entry)
182
+ [ " ~ #{entry[:handle]}#{attribute_list(entry[:changed])}" ].tap do |lines|
183
+ entry[:fields][:add].each { |field| lines << " + field #{field[:handle]} (#{field[:type]})" }
184
+ entry[:fields][:update].each { |field| lines << " ~ field #{field[:handle]}#{attribute_list(field[:changed])}" }
185
+ entry[:fields][:remove].each { |handle| lines << " - field #{handle} #{PRUNE_HINT}" }
186
+ lines << " ~ field order changed" if entry[:reorder]
187
+ end
188
+ end
189
+
190
+ def attribute_list(changed)
191
+ changed.any? ? " (#{changed.join(', ')})" : ""
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,214 @@
1
+ module Iron
2
+ class Schema::Validation
3
+ HANDLE_FORMAT = /\A[a-z0-9_]+\z/
4
+
5
+ # In prune mode, objects in the database but not in the file are about to
6
+ # be destroyed, so only file-declared handles count as known.
7
+ def initialize(schema, prune: false)
8
+ @schema = schema
9
+ @prune = prune
10
+ @problems = []
11
+ end
12
+
13
+ def problems
14
+ validate_locales
15
+ validate_block_definitions
16
+ validate_content_types
17
+ @problems
18
+ end
19
+
20
+ private
21
+
22
+ attr_reader :schema
23
+
24
+ def flag(problem)
25
+ @problems << problem
26
+ end
27
+
28
+ def validate_locales
29
+ seen_codes = Set.new
30
+ (schema[:locales] || []).each_with_index do |attrs, index|
31
+ context = "locales[#{index}]"
32
+ next flag "#{context}: must be an object" unless attrs.is_a?(Hash)
33
+
34
+ flag "#{context}: code is required" if attrs[:code].blank?
35
+ flag "#{context}: name is required" if attrs[:name].blank?
36
+ flag_duplicate(seen_codes, attrs[:code], "code", context)
37
+ end
38
+ end
39
+
40
+ def validate_block_definitions
41
+ seen_handles = Set.new
42
+ (schema[:block_definitions] || []).each_with_index do |attrs, index|
43
+ context = "block_definitions[#{index}]"
44
+ next flag "#{context}: must be an object" unless attrs.is_a?(Hash)
45
+
46
+ validate_handle(attrs[:handle], context)
47
+ flag_duplicate(seen_handles, attrs[:handle], "handle", context)
48
+ flag "#{context}: name is required" if attrs[:name].blank?
49
+ validate_field_definitions(attrs, context)
50
+ end
51
+ end
52
+
53
+ def validate_content_types
54
+ seen_handles = Set.new
55
+ (schema[:content_types] || []).each_with_index do |attrs, index|
56
+ context = "content_types[#{index}]"
57
+ next flag "#{context}: must be an object" unless attrs.is_a?(Hash)
58
+
59
+ validate_handle(attrs[:handle], context)
60
+ flag_duplicate(seen_handles, attrs[:handle], "handle", context)
61
+ flag "#{context}: name is required" if attrs[:name].blank?
62
+ validate_content_type_type(attrs[:type], context)
63
+ validate_icon(attrs[:icon], context)
64
+ validate_field_definitions(attrs, context)
65
+ end
66
+ end
67
+
68
+ def validate_content_type_type(type, context)
69
+ return if %w[single collection].include?(type.to_s)
70
+
71
+ flag %(#{context}: unknown type "#{type}" (valid: single, collection))
72
+ end
73
+
74
+ def validate_icon(icon, context)
75
+ return if icon.blank?
76
+ return if IconCatalog.all.empty? || IconCatalog.include?(icon.to_s)
77
+
78
+ flag %(#{context}: unknown icon "#{icon}" (icons are Lucide names — Iron::IconCatalog.all lists the valid set))
79
+ end
80
+
81
+ def validate_field_definitions(owner_attrs, owner_context)
82
+ seen_handles = Set.new
83
+ (owner_attrs[:field_definitions] || []).each_with_index do |attrs, index|
84
+ context = "#{owner_context}.field_definitions[#{index}]"
85
+ next flag "#{context}: must be an object" unless attrs.is_a?(Hash)
86
+
87
+ validate_field_handle(attrs[:handle], context)
88
+ flag_duplicate(seen_handles, attrs[:handle], "handle", context)
89
+ flag "#{context}: name is required" if attrs[:name].blank?
90
+ validate_field_type(attrs, context)
91
+ validate_field_metadata(attrs, context)
92
+ end
93
+ end
94
+
95
+ def flag_duplicate(seen, value, label, context)
96
+ return if value.blank?
97
+
98
+ flag %(#{context}: duplicate #{label} "#{value}") unless seen.add?(value.to_s)
99
+ end
100
+
101
+ def validate_field_handle(handle, context)
102
+ validate_handle(handle, context)
103
+ flag %(#{context}: handle "#{handle}" is reserved) if FieldDefinition::RESERVED_HANDLES.include?(handle.to_s)
104
+ end
105
+
106
+ def validate_handle(handle, context)
107
+ if handle.blank?
108
+ flag "#{context}: handle is required"
109
+ elsif !handle.to_s.match?(HANDLE_FORMAT)
110
+ flag %(#{context}: handle "#{handle}" must contain only lowercase letters, numbers and underscores)
111
+ end
112
+ end
113
+
114
+ def validate_field_type(attrs, context)
115
+ type = attrs[:type].to_s
116
+ return flag %(#{context}: unknown type "#{type}" (valid: #{FieldDefinition::TYPES.join(', ')})) unless FieldDefinition::TYPES.include?(type)
117
+
118
+ case type
119
+ when "block"
120
+ supported = Array(attrs[:supported_block_definitions])
121
+ flag "#{context}: block fields must list exactly one supported_block_definitions handle" unless supported.size == 1
122
+ validate_known_block_definitions(supported, context)
123
+ when "block_list"
124
+ validate_known_block_definitions(Array(attrs[:supported_block_definitions]), context)
125
+ when "reference", "reference_list"
126
+ validate_known_content_types(Array(attrs[:supported_content_types]), context)
127
+ end
128
+ end
129
+
130
+ def validate_field_metadata(attrs, context)
131
+ type_handle = attrs[:type].to_s
132
+ return unless FieldDefinition::TYPES.include?(type_handle)
133
+
134
+ metadata = attrs[:metadata]
135
+ return if metadata.nil?
136
+ return flag "#{context}: metadata must be an object" unless metadata.is_a?(Hash)
137
+
138
+ metadata = metadata.transform_keys(&:to_s)
139
+ flag_unknown_metadata_keys(type_handle, metadata, context)
140
+ flag_unrestricted_specific_scope(type_handle, metadata, context)
141
+ FieldDefinition::Validations.violations(type_handle, metadata).each do |violation|
142
+ flag metadata_violation(violation, context)
143
+ end
144
+ end
145
+
146
+ def metadata_violation(violation, context)
147
+ return "#{context}: #{violation.message}" if violation.attribute == :metadata
148
+
149
+ "#{context}: metadata.#{violation.attribute} #{violation.message}"
150
+ end
151
+
152
+ def flag_unknown_metadata_keys(type_handle, metadata, context)
153
+ known = known_metadata_keys(type_handle)
154
+ (metadata.keys - FieldDefinition::Validations::RULES.keys - known).each do |key|
155
+ flag "#{context}: #{unknown_metadata_key_message(key, type_handle, known)}"
156
+ end
157
+ end
158
+
159
+ def unknown_metadata_key_message(key, type_handle, known)
160
+ return %(unknown metadata key "#{key}" for #{type_handle} (#{type_handle} supports no metadata keys)) if known.empty?
161
+
162
+ %(unknown metadata key "#{key}" for #{type_handle} (known: #{known.join(', ')}))
163
+ end
164
+
165
+ def known_metadata_keys(type_handle)
166
+ klass = FieldDefinition.classify_type(type_handle).constantize
167
+ keys = klass.supported_validations
168
+ keys |= [ "searchable" ] if FieldDefinition::Searchable::SEARCHABLE_TYPES.include?(klass.name)
169
+ keys
170
+ end
171
+
172
+ def flag_unrestricted_specific_scope(type_handle, metadata, context)
173
+ return unless type_handle == "file" && metadata["file_scope"] == "specific"
174
+
175
+ presets = FieldDefinitions::File::FILE_TYPE_PRESETS.keys
176
+ return if Array(metadata["selected_presets"]).map(&:to_s).intersect?(presets)
177
+
178
+ flag %(#{context}: metadata.selected_presets must list at least one valid preset (#{presets.join(', ')}) when file_scope is "specific")
179
+ end
180
+
181
+ def validate_known_block_definitions(handles, context)
182
+ (handles.map(&:to_s) - known_block_definition_handles).each do |handle|
183
+ flag %(#{context}: unknown block definition "#{handle}")
184
+ end
185
+ end
186
+
187
+ def validate_known_content_types(handles, context)
188
+ (handles.map(&:to_s) - known_content_type_handles).each do |handle|
189
+ flag %(#{context}: unknown content type "#{handle}")
190
+ end
191
+ end
192
+
193
+ def known_block_definition_handles
194
+ @known_block_definition_handles ||= known_handles(:block_definitions, BlockDefinition)
195
+ end
196
+
197
+ def known_content_type_handles
198
+ @known_content_type_handles ||= known_handles(:content_types, ContentType)
199
+ end
200
+
201
+ def known_handles(section, schemable_class)
202
+ declared = declared_handles(section)
203
+ prune? ? declared : declared | schemable_class.pluck(:handle)
204
+ end
205
+
206
+ def prune?
207
+ @prune
208
+ end
209
+
210
+ def declared_handles(section)
211
+ (schema[section] || []).filter_map { |attrs| attrs[:handle].to_s if attrs.is_a?(Hash) }
212
+ end
213
+ end
214
+ end
@@ -0,0 +1,282 @@
1
+ module Iron
2
+ class Schema
3
+ DEFAULT_PATH = "db/cms/schema.json"
4
+
5
+ class_attribute :editable_in_environment, instance_accessor: false, default: ->(env) { env.development? || env.test? }
6
+
7
+ class FileMissing < StandardError; end
8
+
9
+ class InvalidSchema < StandardError
10
+ attr_reader :problems
11
+
12
+ def initialize(problems)
13
+ @problems = Array(problems)
14
+ super(@problems.join("\n"))
15
+ end
16
+ end
17
+
18
+ class << self
19
+ include Lexorank::Utils
20
+
21
+ def editable?
22
+ editable_in_environment.call(Rails.env)
23
+ end
24
+
25
+ def locked?
26
+ !editable?
27
+ end
28
+
29
+ def path_for(path = nil)
30
+ Pathname.new(path || Rails.root.join(DEFAULT_PATH))
31
+ end
32
+
33
+ def dump(path = nil)
34
+ path_for(path).tap do |file|
35
+ FileUtils.mkdir_p(file.dirname)
36
+ file.write("#{to_json}\n")
37
+ end
38
+ end
39
+
40
+ def to_json
41
+ JSON.pretty_generate(export_hash)
42
+ end
43
+
44
+ def export_hash
45
+ {
46
+ version: "1.0",
47
+ default_locale: Current.account&.default_locale&.code,
48
+ locales: export_locales,
49
+ block_definitions: export_block_definitions,
50
+ content_types: export_content_types
51
+ }
52
+ end
53
+
54
+ def diff(path = nil)
55
+ schema = parse(path)
56
+ validate!(schema)
57
+
58
+ Diff.new(schema)
59
+ end
60
+
61
+ def apply(path = nil, prune: false)
62
+ schema = parse(path)
63
+ validate!(schema, prune:)
64
+
65
+ Diff.new(schema).tap do |diff|
66
+ suppress_auto_dump do
67
+ ActiveRecord::Base.transaction do
68
+ import(schema)
69
+ prune!(diff) if prune
70
+ end
71
+ end
72
+ end
73
+ end
74
+
75
+ def import(schema)
76
+ suppress_auto_dump do
77
+ ActiveRecord::Base.transaction do
78
+ import_locales(schema[:locales] || [])
79
+ ensure_account!(schema)
80
+ import_block_definitions(schema[:block_definitions] || [])
81
+ populate_block_field_definitions(schema[:block_definitions] || [])
82
+ import_content_types(schema[:content_types] || [])
83
+ resolve_supported_definitions(schema)
84
+ resolve_content_type_references(schema[:content_types] || [])
85
+ restore_default_locale(schema[:default_locale])
86
+ end
87
+ end
88
+ end
89
+
90
+ def note_change
91
+ ActiveSupport::IsolatedExecutionState[:iron_schema_change_noted] = true
92
+ end
93
+
94
+ def forget_change
95
+ ActiveSupport::IsolatedExecutionState[:iron_schema_change_noted] = nil
96
+ end
97
+
98
+ def auto_dump
99
+ return unless change_noted?
100
+ forget_change
101
+
102
+ return unless editable?
103
+ return if auto_dump_suppressed?
104
+ return unless path_for.exist?
105
+
106
+ dump
107
+ end
108
+
109
+ def suppress_auto_dump
110
+ previous = ActiveSupport::IsolatedExecutionState[:iron_schema_auto_dump_suppressed]
111
+ ActiveSupport::IsolatedExecutionState[:iron_schema_auto_dump_suppressed] = true
112
+ yield
113
+ ensure
114
+ ActiveSupport::IsolatedExecutionState[:iron_schema_auto_dump_suppressed] = previous
115
+ end
116
+
117
+ private
118
+
119
+ def parse(path)
120
+ file = path_for(path)
121
+ raise FileMissing, "No schema file at #{file} — run bin/rails iron:schema:dump to create one from the current database" unless file.exist?
122
+
123
+ schema = JSON.parse(file.read, symbolize_names: true)
124
+ raise InvalidSchema, [ "schema must be a JSON object" ] unless schema.is_a?(Hash)
125
+
126
+ schema
127
+ rescue JSON::ParserError => error
128
+ raise InvalidSchema, [ "#{file}: #{error.message}" ]
129
+ end
130
+
131
+ def validate!(schema, prune: false)
132
+ problems = Validation.new(schema, prune:).problems
133
+ raise InvalidSchema, problems if problems.any?
134
+ end
135
+
136
+ def auto_dump_suppressed?
137
+ !!ActiveSupport::IsolatedExecutionState[:iron_schema_auto_dump_suppressed]
138
+ end
139
+
140
+ def change_noted?
141
+ !!ActiveSupport::IsolatedExecutionState[:iron_schema_change_noted]
142
+ end
143
+
144
+ def export_locales
145
+ Locale.order(:code).map { |locale| { code: locale.code, name: locale.name } }
146
+ end
147
+
148
+ def export_block_definitions
149
+ BlockDefinition.order(:handle).map(&:export_attributes)
150
+ end
151
+
152
+ def export_content_types
153
+ ContentType.order(:handle).map(&:export_attributes)
154
+ end
155
+
156
+ def import_locales(locales)
157
+ locales.each do |attrs|
158
+ locale = Locale.find_or_create_by!(code: attrs[:code]) do |new_locale|
159
+ new_locale.name = attrs[:name]
160
+ end
161
+ locale.update!(name: attrs[:name]) if attrs[:name].present? && locale.name != attrs[:name]
162
+ end
163
+ end
164
+
165
+ def ensure_account!(schema)
166
+ Account.first || Account.create!(name: "Iron", default_locale: bootstrap_locale(schema))
167
+ end
168
+
169
+ def bootstrap_locale(schema)
170
+ Locale.find_by(code: schema[:default_locale]) ||
171
+ Locale.first ||
172
+ Locale.find_or_create_by!(code: "en") { |locale| locale.name = "English" }
173
+ end
174
+
175
+ def import_block_definitions(definitions)
176
+ definitions.each { |attrs| BlockDefinition.import_from_attributes(attrs) }
177
+ end
178
+
179
+ def populate_block_field_definitions(definitions)
180
+ definitions.each do |attrs|
181
+ BlockDefinition.populate_field_definitions(attrs)
182
+ align_field_order(BlockDefinition.find_by!(handle: attrs[:handle]), attrs[:field_definitions] || [])
183
+ end
184
+ end
185
+
186
+ def import_content_types(definitions)
187
+ definitions.each do |attrs|
188
+ content_type = ContentType.import_from_attributes(attrs)
189
+ align_field_order(content_type, attrs[:field_definitions] || [])
190
+ end
191
+ end
192
+
193
+ # Schema files carry no ranks: array order is the field order. Ranks are
194
+ # only rewritten when the relative order in the database disagrees, so a
195
+ # no-op apply never churns updated_at.
196
+ def align_field_order(schemable, field_attrs_list)
197
+ file_handles = field_attrs_list.map { |attrs| attrs[:handle].to_s }
198
+ listed = schemable.field_definitions.reload.select { |definition| file_handles.include?(definition.handle) }
199
+ return if listed.map(&:handle) == file_handles
200
+
201
+ interval = optimal_rank_numeric_interval_for(file_handles.size)
202
+ by_handle = listed.index_by(&:handle)
203
+ file_handles.each.with_index(1) do |handle, index|
204
+ by_handle[handle]&.update!(rank: to_rank(interval * index))
205
+ end
206
+ end
207
+
208
+ # Fields can support content types declared later in the file, so the
209
+ # supported lists are wired again once every definition exists.
210
+ def resolve_supported_definitions(schema)
211
+ repopulate_supported_definitions(BlockDefinition, schema[:block_definitions] || [])
212
+ repopulate_supported_definitions(ContentType, schema[:content_types] || [])
213
+ end
214
+
215
+ def repopulate_supported_definitions(schemable_class, definitions)
216
+ definitions.each do |attrs|
217
+ schemable = schemable_class.find_by(handle: attrs[:handle])
218
+ next unless schemable
219
+
220
+ (attrs[:field_definitions] || []).each do |field_attrs|
221
+ field = schemable.field_definitions.find_by(handle: field_attrs[:handle])
222
+ FieldDefinition.assign_supported_definitions(field, field_attrs) if field
223
+ end
224
+ end
225
+ end
226
+
227
+ # Omitted handles clear the reference: the file is authoritative.
228
+ def resolve_content_type_references(definitions)
229
+ definitions.each do |attrs|
230
+ content_type = ContentType.find_by(handle: attrs[:handle])
231
+ next unless content_type
232
+
233
+ content_type.update!(
234
+ title_field_definition: content_type.field_definitions.find_by(handle: attrs[:title_field_handle]),
235
+ web_page_title_field_definition: content_type.field_definitions.find_by(handle: attrs[:web_page_title_field_handle])
236
+ )
237
+ end
238
+ end
239
+
240
+ def restore_default_locale(locale_code)
241
+ return unless locale_code
242
+
243
+ locale = Locale.find_by(code: locale_code)
244
+ Current.account.update!(default_locale: locale) if locale
245
+ end
246
+
247
+ def prune!(diff)
248
+ removals = diff.removals
249
+
250
+ removals[:content_types].each { |handle| ContentType.find_by(handle:)&.destroy! }
251
+ removals[:block_definitions].each { |handle| BlockDefinition.find_by(handle:)&.destroy! }
252
+
253
+ prune_field_definitions(ContentType, removals[:content_type_fields])
254
+ prune_field_definitions(BlockDefinition, removals[:block_definition_fields])
255
+
256
+ prune_locales(removals[:locales], diff)
257
+ end
258
+
259
+ def prune_field_definitions(schemable_class, field_handles_by_schemable)
260
+ field_handles_by_schemable.each do |schemable_handle, field_handles|
261
+ schemable_class.find_by(handle: schemable_handle)&.field_definitions&.where(handle: field_handles)&.destroy_all
262
+ end
263
+ end
264
+
265
+ def prune_locales(codes, diff)
266
+ Locale.where(code: codes).find_each do |locale|
267
+ if locale.default?
268
+ diff.add_note %(locale "#{locale.code}" is the account default locale — left in place)
269
+ elsif locale_has_content?(locale)
270
+ diff.add_note %(locale "#{locale.code}" still has content — left in place (delete its content first))
271
+ else
272
+ locale.destroy!
273
+ end
274
+ end
275
+ end
276
+
277
+ def locale_has_content?(locale)
278
+ Field.exists?(locale:) || SearchRecord.exists?(locale:)
279
+ end
280
+ end
281
+ end
282
+ end
@@ -14,9 +14,11 @@ module Iron
14
14
  return unless path.exist?
15
15
  return if already_seeded?
16
16
 
17
- user, account = bootstrap
18
- Current.set(user: user, account: account) do
19
- Importer.new(path).import
17
+ Schema.suppress_auto_dump do
18
+ user, account = bootstrap
19
+ Current.set(user: user, account: account) do
20
+ Importer.new(path).import
21
+ end
20
22
  end
21
23
  end
22
24