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.
- checksums.yaml +4 -4
- data/README.md +88 -4
- data/app/assets/builds/iron.css +255 -106
- data/app/assets/tailwind/iron/application.css +1 -0
- data/app/assets/tailwind/iron/components/file-upload.css +26 -0
- data/app/assets/tailwind/iron/lexxy.css +111 -87
- data/app/controllers/concerns/iron/schema_editing.rb +19 -0
- data/app/controllers/iron/api/schema/base_controller.rb +25 -0
- data/app/controllers/iron/api/schema/block_definitions_controller.rb +49 -0
- data/app/controllers/iron/api/schema/content_types_controller.rb +64 -0
- data/app/controllers/iron/api/schema/field_definitions_controller.rb +88 -0
- data/app/controllers/iron/api/schema/locales_controller.rb +52 -0
- data/app/controllers/iron/application_controller.rb +1 -1
- data/app/controllers/iron/block_definitions_controller.rb +1 -0
- data/app/controllers/iron/content_types_controller.rb +1 -0
- data/app/controllers/iron/field_definitions_controller.rb +2 -1
- data/app/controllers/iron/first_runs_controller.rb +1 -1
- data/app/controllers/iron/locales_controller.rb +1 -0
- data/app/controllers/iron/sessions_controller.rb +1 -1
- data/app/helpers/iron/avatar_helper.rb +24 -2
- data/app/javascript/iron/controllers/file_upload_controller.js +38 -7
- data/app/models/iron/account.rb +1 -1
- data/app/models/iron/api/openapi_spec.rb +37 -15
- data/app/models/iron/block_definition/exportable.rb +1 -1
- data/app/models/iron/block_definition.rb +3 -1
- data/app/models/iron/content.rb +176 -0
- data/app/models/iron/content_type/exportable.rb +3 -1
- data/app/models/iron/content_type/importable.rb +1 -1
- data/app/models/iron/content_type.rb +6 -1
- data/app/models/iron/entry/content_assignable.rb +10 -2
- data/app/models/iron/exporter.rb +1 -26
- data/app/models/iron/field/length_constrained.rb +17 -0
- data/app/models/iron/field/validatable.rb +16 -0
- data/app/models/iron/field.rb +1 -1
- data/app/models/iron/field_definition/exportable.rb +3 -4
- data/app/models/iron/field_definition/importable.rb +16 -9
- data/app/models/iron/field_definition/ranked.rb +1 -1
- data/app/models/iron/field_definition/searchable.rb +2 -0
- data/app/models/iron/field_definition/validatable.rb +40 -0
- data/app/models/iron/field_definition/validations.rb +175 -0
- data/app/models/iron/field_definition.rb +1 -1
- data/app/models/iron/field_definitions/date.rb +2 -0
- data/app/models/iron/field_definitions/file.rb +3 -11
- data/app/models/iron/field_definitions/number.rb +2 -0
- data/app/models/iron/field_definitions/reference.rb +2 -0
- data/app/models/iron/field_definitions/rich_text_area.rb +1 -0
- data/app/models/iron/field_definitions/text_area.rb +2 -0
- data/app/models/iron/field_definitions/text_field.rb +1 -9
- data/app/models/iron/fields/block.rb +5 -1
- data/app/models/iron/fields/block_list.rb +5 -1
- data/app/models/iron/fields/date.rb +19 -2
- data/app/models/iron/fields/file.rb +5 -3
- data/app/models/iron/fields/number.rb +28 -1
- data/app/models/iron/fields/reference.rb +2 -0
- data/app/models/iron/fields/rich_text_area.rb +2 -0
- data/app/models/iron/fields/text_area.rb +4 -0
- data/app/models/iron/fields/text_field.rb +9 -5
- data/app/models/iron/importer.rb +1 -54
- data/app/models/iron/locale.rb +2 -0
- data/app/models/iron/schema/auto_dumpable.rb +26 -0
- data/app/models/iron/schema/diff.rb +194 -0
- data/app/models/iron/schema/validation.rb +214 -0
- data/app/models/iron/schema.rb +282 -0
- data/app/models/iron/seed.rb +5 -3
- data/app/models/iron/system.rb +7 -0
- data/app/models/iron/user/deactivatable.rb +5 -0
- data/app/models/iron/user.rb +18 -1
- data/app/views/iron/api/fields/_rich_text_area.json.jbuilder +2 -2
- data/app/views/iron/api/schema/block_definitions/_block_definition.json.jbuilder +4 -0
- data/app/views/iron/api/schema/block_definitions/index.json.jbuilder +1 -0
- data/app/views/iron/api/schema/block_definitions/show.json.jbuilder +1 -0
- data/app/views/iron/api/schema/content_types/_content_type.json.jbuilder +5 -0
- data/app/views/iron/api/schema/content_types/index.json.jbuilder +1 -0
- data/app/views/iron/api/schema/content_types/show.json.jbuilder +1 -0
- data/app/views/iron/api/schema/field_definitions/_field_definition.json.jbuilder +7 -0
- data/app/views/iron/api/schema/field_definitions/show.json.jbuilder +1 -0
- data/app/views/iron/api/schema/locales/_locale.json.jbuilder +4 -0
- data/app/views/iron/api/schema/locales/index.json.jbuilder +1 -0
- data/app/views/iron/api/schema/locales/show.json.jbuilder +1 -0
- data/app/views/iron/block_definitions/_empty_state.html.erb +5 -3
- data/app/views/iron/block_definitions/_form.html.erb +3 -1
- data/app/views/iron/block_definitions/edit.html.erb +19 -17
- data/app/views/iron/block_definitions/index.html.erb +7 -3
- data/app/views/iron/block_definitions/show.html.erb +14 -8
- data/app/views/iron/content_types/_content_type.html.erb +1 -1
- data/app/views/iron/content_types/_empty_state.html.erb +5 -3
- data/app/views/iron/content_types/_form.html.erb +12 -8
- data/app/views/iron/content_types/edit.html.erb +19 -17
- data/app/views/iron/content_types/index.html.erb +7 -3
- data/app/views/iron/content_types/show.html.erb +14 -8
- data/app/views/iron/entries/_empty_state.html.erb +1 -1
- data/app/views/iron/entries/edit.html.erb +1 -1
- data/app/views/iron/entries/entry.html.erb +1 -1
- data/app/views/iron/entries/fields/_block.html.erb +4 -0
- data/app/views/iron/entries/fields/_block_list.html.erb +2 -0
- data/app/views/iron/entries/fields/_boolean.html.erb +1 -0
- data/app/views/iron/entries/fields/_date.html.erb +3 -2
- data/app/views/iron/entries/fields/_field_errors.html.erb +7 -0
- data/app/views/iron/entries/fields/_field_label.html.erb +8 -0
- data/app/views/iron/entries/fields/_file.html.erb +11 -19
- data/app/views/iron/entries/fields/_number.html.erb +6 -2
- data/app/views/iron/entries/fields/_reference.html.erb +2 -1
- data/app/views/iron/entries/fields/_reference_list.html.erb +2 -0
- data/app/views/iron/entries/fields/_rich_text_area.html.erb +2 -1
- data/app/views/iron/entries/fields/_text_area.html.erb +6 -2
- data/app/views/iron/entries/fields/_text_field.html.erb +5 -12
- data/app/views/iron/field_definitions/_field_definition.html.erb +51 -29
- data/app/views/iron/field_definitions/date/_form.html.erb +5 -1
- data/app/views/iron/field_definitions/edit.html.erb +10 -8
- data/app/views/iron/field_definitions/file/_form.html.erb +6 -0
- data/app/views/iron/field_definitions/new.html.erb +5 -3
- data/app/views/iron/field_definitions/number/_form.html.erb +23 -1
- data/app/views/iron/field_definitions/reference/_form.html.erb +5 -0
- data/app/views/iron/field_definitions/rich_text_area/_form.html.erb +5 -0
- data/app/views/iron/field_definitions/text_area/_form.html.erb +23 -0
- data/app/views/iron/field_definitions/text_field/_form.html.erb +20 -1
- data/app/views/iron/home/_content_types.html.erb +1 -1
- data/app/views/iron/locales/_form.html.erb +5 -3
- data/app/views/iron/locales/edit.html.erb +1 -1
- data/app/views/iron/locales/index.html.erb +12 -6
- data/app/views/iron/shared/_schema_lock_badge.html.erb +19 -0
- data/config/locales/en.yml +13 -1
- data/config/locales/it.yml +18 -1
- data/config/routes.rb +11 -0
- data/db/migrate/20260612131538_create_iron_systems.rb +9 -0
- data/exe/iron +5 -0
- data/lib/generators/iron/agents/agents_generator.rb +52 -0
- data/lib/generators/iron/agents/templates/AGENTS.md +24 -0
- data/lib/generators/iron/agents/templates/SKILL.md +423 -0
- data/lib/generators/iron/install/install_generator.rb +118 -0
- data/lib/generators/iron/install/templates/iron_release.rake +5 -0
- data/lib/generators/iron/install/templates/schema.json +12 -0
- data/lib/generators/iron/install/templates/seeds.rb +13 -0
- data/lib/generators/iron/pages/pages_generator.rb +5 -0
- data/lib/generators/iron/pages/templates/pages_controller.rb +1 -1
- data/lib/generators/iron/pages/templates/show.html.erb +1 -1
- data/lib/install/template.rb +9 -0
- data/lib/iron/cli.rb +43 -0
- data/lib/iron/version.rb +1 -1
- data/lib/tasks/iron_content.rake +82 -0
- data/lib/tasks/iron_schema.rake +45 -0
- 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
|
data/app/models/iron/seed.rb
CHANGED
|
@@ -14,9 +14,11 @@ module Iron
|
|
|
14
14
|
return unless path.exist?
|
|
15
15
|
return if already_seeded?
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|