lcp 0.1.0 → 0.2.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 (109) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/lcp-custom-field/SKILL.md +10 -1
  3. data/.claude/skills/lcp-custom-field/agents/openai.yaml +4 -0
  4. data/.claude/skills/lcp-getting-started/SKILL.md +1 -1
  5. data/.claude/skills/lcp-getting-started/agents/openai.yaml +4 -0
  6. data/.claude/skills/lcp-host-binding/agents/openai.yaml +4 -0
  7. data/.claude/skills/lcp-model/agents/openai.yaml +4 -0
  8. data/.claude/skills/lcp-permissions/agents/openai.yaml +4 -0
  9. data/.claude/skills/lcp-presenter/agents/openai.yaml +4 -0
  10. data/.claude/skills/lcp-workflow/SKILL.md +10 -2
  11. data/.claude/skills/lcp-workflow/agents/openai.yaml +4 -0
  12. data/CHANGELOG.md +59 -1
  13. data/app/assets/javascripts/lcp_ruby/dev_toolbar.js +5 -6
  14. data/app/controllers/concerns/lcp_ruby/zone_resolution.rb +14 -1
  15. data/app/helpers/lcp_ruby/display/card_helper.rb +71 -16
  16. data/app/helpers/lcp_ruby/display_helper.rb +30 -3
  17. data/app/helpers/lcp_ruby/display_template_helper.rb +3 -3
  18. data/app/helpers/lcp_ruby/layout_helper.rb +49 -8
  19. data/app/helpers/lcp_ruby/tree_helper.rb +24 -3
  20. data/app/models/lcp_ruby/user.rb +10 -0
  21. data/app/views/layouts/lcp_ruby/application.html.erb +42 -20
  22. data/app/views/layouts/lcp_ruby/auth.html.erb +6 -1
  23. data/app/views/lcp_ruby/resources/_show_sections.html.erb +24 -17
  24. data/app/views/lcp_ruby/resources/_table_index.html.erb +12 -17
  25. data/app/views/lcp_ruby/widgets/_markdown.html.erb +13 -0
  26. data/app/views/lcp_ruby/widgets/_presenter_zone.html.erb +23 -7
  27. data/app/views/lcp_ruby/widgets/_rich_text.html.erb +25 -0
  28. data/docs/README.md +3 -0
  29. data/docs/feature-catalog.md +5 -2
  30. data/docs/feature-catalog.yml +89 -9
  31. data/docs/getting-started.md +38 -22
  32. data/docs/guides/dashboards.md +44 -1
  33. data/docs/guides/display-types.md +6 -0
  34. data/docs/guides/host-application.md +1 -1
  35. data/docs/guides/windows-setup.md +211 -0
  36. data/docs/guides/workflow.md +1 -1
  37. data/docs/reference/asset-pipeline.md +79 -0
  38. data/docs/reference/pages.md +50 -1
  39. data/docs/reference/presenters.md +6 -0
  40. data/docs/reference/workflow.md +2 -2
  41. data/examples/crm/Gemfile +3 -0
  42. data/examples/crm/Gemfile.lock +14 -9
  43. data/examples/crm/app/assets/config/manifest.js +4 -0
  44. data/examples/hr/Gemfile +3 -0
  45. data/examples/hr/Gemfile.lock +14 -9
  46. data/examples/hr/app/assets/config/manifest.js +4 -0
  47. data/examples/showcase/Gemfile +3 -0
  48. data/examples/showcase/Gemfile.lock +12 -9
  49. data/examples/showcase/app/assets/config/manifest.js +4 -0
  50. data/examples/showcase/config/lcp_ruby/pages/main_dashboard.yml +17 -2
  51. data/examples/showcase/config/locales/cs.yml +8 -0
  52. data/examples/showcase/config/locales/en.yml +8 -0
  53. data/examples/todo/Gemfile +3 -0
  54. data/examples/todo/Gemfile.lock +14 -9
  55. data/examples/todo/app/assets/config/manifest.js +4 -0
  56. data/exe/lcp +1 -1
  57. data/lib/generators/lcp_ruby/agent_setup_generator.rb +12 -9
  58. data/lib/generators/lcp_ruby/claude_skills_generator.rb +23 -19
  59. data/lib/generators/lcp_ruby/install_generator.rb +171 -3
  60. data/lib/generators/lcp_ruby/templates/agent_setup/agents_md.md +2 -1
  61. data/lib/generators/lcp_ruby/templates/agent_setup/claude_md.md +3 -2
  62. data/lib/generators/lcp_ruby/templates/install/menu.yml.tt +4 -0
  63. data/lib/generators/lcp_ruby/templates/monitoring/page.yml +5 -0
  64. data/lib/lcp_ruby/app_template.rb +37 -1
  65. data/lib/lcp_ruby/array_query.rb +33 -10
  66. data/lib/lcp_ruby/cli/new_command.rb +9 -4
  67. data/lib/lcp_ruby/cli/run_command.rb +98 -11
  68. data/lib/lcp_ruby/cli/skills_command.rb +27 -15
  69. data/lib/lcp_ruby/cli.rb +5 -1
  70. data/lib/lcp_ruby/configuration.rb +13 -2
  71. data/lib/lcp_ruby/custom_fields/applicator.rb +8 -0
  72. data/lib/lcp_ruby/custom_fields/query.rb +28 -20
  73. data/lib/lcp_ruby/display/markdown_sanitize.rb +36 -0
  74. data/lib/lcp_ruby/display/renderers/markdown.rb +6 -13
  75. data/lib/lcp_ruby/engine.rb +185 -1
  76. data/lib/lcp_ruby/export/data_generator.rb +5 -1
  77. data/lib/lcp_ruby/export/value_formatter.rb +34 -0
  78. data/lib/lcp_ruby/grouped_query/builder.rb +21 -0
  79. data/lib/lcp_ruby/metadata/configuration_validator.rb +5 -5
  80. data/lib/lcp_ruby/metadata/field_path.rb +57 -0
  81. data/lib/lcp_ruby/metadata/loader.rb +33 -1
  82. data/lib/lcp_ruby/metadata/menu_definition.rb +32 -2
  83. data/lib/lcp_ruby/metadata/menu_item.rb +53 -4
  84. data/lib/lcp_ruby/metadata/model_definition.rb +9 -0
  85. data/lib/lcp_ruby/metadata/zone_definition.rb +12 -5
  86. data/lib/lcp_ruby/metrics/json_query.rb +5 -0
  87. data/lib/lcp_ruby/model_factory/builder.rb +5 -0
  88. data/lib/lcp_ruby/model_factory/json_type_applicator.rb +35 -0
  89. data/lib/lcp_ruby/model_factory/label_method_builder.rb +3 -19
  90. data/lib/lcp_ruby/model_factory/schema_manager.rb +19 -0
  91. data/lib/lcp_ruby/pages/definition_validator.rb +13 -0
  92. data/lib/lcp_ruby/presenter/field_value_resolver.rb +36 -19
  93. data/lib/lcp_ruby/schemas/page.json +33 -1
  94. data/lib/lcp_ruby/search/custom_field_filter.rb +5 -0
  95. data/lib/lcp_ruby/skills_installer.rb +15 -2
  96. data/lib/lcp_ruby/tasks/doctor.rb +13 -8
  97. data/lib/lcp_ruby/version.rb +1 -1
  98. data/lib/lcp_ruby/widgets/data_resolver.rb +42 -1
  99. data/lib/lcp_ruby/widgets/date_grouper.rb +19 -0
  100. data/lib/lcp_ruby/workflow/contract_validator.rb +1 -1
  101. data/lib/lcp_ruby/workflow/model_source.rb +2 -2
  102. data/lib/lcp_ruby.rb +18 -0
  103. data/lib/tasks/lcp_ruby_db.rake +8 -1
  104. data/lib/tasks/lcp_ruby_i18n_check.rake +8 -0
  105. metadata +34 -24
  106. data/examples/crm/erd.md +0 -163
  107. data/examples/hr/erd.md +0 -396
  108. data/examples/showcase/erd.md +0 -567
  109. data/examples/todo/erd.md +0 -21
@@ -78,7 +78,11 @@ module LcpRuby
78
78
  end
79
79
 
80
80
  def resolve_and_format(record, field_path, output: :csv)
81
- value = @field_value_resolver.resolve(record, field_path, raw: @raw)
81
+ # Resolver now always returns raw enum keys; the formatter is the
82
+ # single source of enum-mode rendering (`label` / `key` / `both`).
83
+ # The `@raw` flag controls the formatter's native-type output for
84
+ # raw `.json` / `.csv` endpoints.
85
+ value = @field_value_resolver.resolve(record, field_path)
82
86
  field_def, model_name = field_meta[field_path]
83
87
 
84
88
  @formatter.format(value, field_definition: field_def, model_name: model_name, raw: @raw, output: output)
@@ -30,6 +30,32 @@ module LcpRuby
30
30
 
31
31
  type = field_definition&.type || infer_type(value)
32
32
 
33
+ # has_many → terminal dot-paths (e.g. `tasks.status`, `tasks.due_date`,
34
+ # `tasks.title`) deliver an Array of terminal values. Format each element
35
+ # through the SAME type logic and recombine — a single chokepoint so
36
+ # every type is handled, not just enum. Without it, non-enum Arrays fell
37
+ # through to scalar parsers: `format_date([d1, d2])` parsed only the
38
+ # first date (silent data loss) and string terminals emitted the Ruby
39
+ # array literal `["a", "b"]`.
40
+ #
41
+ # Gate on the EXPLICIT field type, not `type`: only a declared `json`
42
+ # field holds an Array that must serialize as JSON. When the field def
43
+ # can't be resolved (field_definition nil), `type` would be
44
+ # `infer_type(Array) == "json"` and wrongly skip the join — so a
45
+ # has_many dot-path with no resolvable terminal would still emit the
46
+ # Ruby array literal. Checking field_definition&.type keeps that case
47
+ # joined.
48
+ if value.is_a?(Array) && field_definition&.type != "json"
49
+ formatted = value.map do |v|
50
+ format(v, field_definition: field_definition, model_name: model_name, raw: raw, output: output)
51
+ end
52
+ # Raw JSON wants a native array of typed values; CSV and the
53
+ # locale-formatted label modes want a single joined cell.
54
+ return formatted if raw && output != :csv
55
+
56
+ return formatted.compact.join(array_separator)
57
+ end
58
+
33
59
  return format_raw(value, type, output) if raw
34
60
 
35
61
  case type
@@ -57,6 +83,8 @@ module LcpRuby
57
83
  def format_raw(value, type, output)
58
84
  case type
59
85
  when "enum"
86
+ # Array values are joined/native-arrayed by the #format chokepoint
87
+ # before dispatch, so this only sees scalar raw keys.
60
88
  value.to_s
61
89
  when "date"
62
90
  parse_date(value)&.iso8601 || value.to_s
@@ -124,6 +152,8 @@ module LcpRuby
124
152
  end
125
153
 
126
154
  def format_enum(value, field_def, model_name)
155
+ # Array values (has_many enum dot-paths) are handled by the Array
156
+ # chokepoint in #format before dispatch, so this only sees scalars.
127
157
  mode = @options["enum_mode"] || "label"
128
158
  key = value.to_s
129
159
 
@@ -138,6 +168,10 @@ module LcpRuby
138
168
  end
139
169
  end
140
170
 
171
+ def array_separator
172
+ @options["array_separator"] || ", "
173
+ end
174
+
141
175
  def enum_label(key, field_def, model_name)
142
176
  return key.humanize unless field_def
143
177
  field_def.enum_label_for(key, model_name: model_name)
@@ -181,6 +181,8 @@ module LcpRuby
181
181
  def date_trunc_sql(period, col_ref, conn)
182
182
  if LcpRuby.postgresql?
183
183
  "DATE_TRUNC('#{period}', #{col_ref})"
184
+ elsif LcpRuby.mysql?
185
+ mysql_trunc(period, col_ref)
184
186
  else
185
187
  sqlite_trunc(period, col_ref)
186
188
  end
@@ -200,6 +202,25 @@ module LcpRuby
200
202
  "strftime('#{format}', #{col_ref})"
201
203
  end
202
204
  end
205
+
206
+ # MySQL/MariaDB has no strftime; DATE_FORMAT produces the same period-key
207
+ # strings ("2026-01", "2026") that GroupedQueryHelper#parse_period_value
208
+ # consumes, so the helper stays adapter-agnostic. Quarter is built with
209
+ # QUARTER() to match SQLite's "2026-Q1" shape.
210
+ def mysql_trunc(period, col_ref)
211
+ case period
212
+ when "quarter"
213
+ "CONCAT(YEAR(#{col_ref}), '-Q', QUARTER(#{col_ref}))"
214
+ else
215
+ format = case period
216
+ when "day" then "%Y-%m-%d"
217
+ when "week" then "%Y-%u"
218
+ when "month" then "%Y-%m"
219
+ when "year" then "%Y"
220
+ end
221
+ "DATE_FORMAT(#{col_ref}, '#{format}')"
222
+ end
223
+ end
203
224
  end
204
225
  end
205
226
  end
@@ -5617,7 +5617,7 @@ module LcpRuby
5617
5617
  validate_workflow_graph_widget(page, zone, widget)
5618
5618
  end
5619
5619
 
5620
- if widget["link_to"] && widget_type != "text"
5620
+ if widget["link_to"] && !LcpRuby::Metadata::ZoneDefinition::I18N_TEXT_WIDGET_TYPES.include?(widget_type)
5621
5621
  unless all_slugs.include?(widget["link_to"])
5622
5622
  @warnings << "Page '#{page.name}', zone '#{zone.name}': widget link_to '#{widget["link_to"]}' " \
5623
5623
  "does not match any known page slug"
@@ -5858,8 +5858,8 @@ module LcpRuby
5858
5858
 
5859
5859
  model_name = resolve_zone_model(page, zone)
5860
5860
  unless model_name
5861
- if zone.widget? && zone.widget["type"] == "text"
5862
- @warnings << "Page '#{page.name}', zone '#{zone.name}': text widget has scope but no model context"
5861
+ if zone.widget? && LcpRuby::Metadata::ZoneDefinition::I18N_TEXT_WIDGET_TYPES.include?(zone.widget["type"])
5862
+ @warnings << "Page '#{page.name}', zone '#{zone.name}': #{zone.widget["type"]} widget has scope but no model context"
5863
5863
  end
5864
5864
  return
5865
5865
  end
@@ -5876,8 +5876,8 @@ module LcpRuby
5876
5876
 
5877
5877
  model_name = resolve_zone_model(page, zone)
5878
5878
  unless model_name
5879
- if zone.widget? && zone.widget["type"] == "text"
5880
- @warnings << "Page '#{page.name}', zone '#{zone.name}': text widget has default_scope but no model context"
5879
+ if zone.widget? && LcpRuby::Metadata::ZoneDefinition::I18N_TEXT_WIDGET_TYPES.include?(zone.widget["type"])
5880
+ @warnings << "Page '#{page.name}', zone '#{zone.name}': #{zone.widget["type"]} widget has default_scope but no model context"
5881
5881
  end
5882
5882
  return
5883
5883
  end
@@ -0,0 +1,57 @@
1
+ module LcpRuby
2
+ module Metadata
3
+ # Walks a dot-path field reference through metadata to find the terminal
4
+ # field definition and its owning model name.
5
+ #
6
+ # Used by view templates and helpers that need to humanize/route an enum
7
+ # value coming from a cross-model field path like `"contact.tier"` or
8
+ # `"order.customer.status"`. The terminal model_name is the i18n
9
+ # namespace under which `enum_label_for` should look up labels
10
+ # (`lcp_ruby.enums.<model>.<field>.*`).
11
+ #
12
+ # Returns `[FieldDefinition, model_name]` or `[nil, nil]` when:
13
+ # - model_def is nil or path is blank
14
+ # - any intermediate segment is missing / non-LCP
15
+ # - an intermediate segment is a collection (`has_many`) and
16
+ # `through_collections:` is false (the default)
17
+ # - any segment cannot be loaded from the metadata loader
18
+ #
19
+ # `through_collections:` — when false (default), an intermediate
20
+ # `has_many` aborts the walk (`[nil, nil]`). This is what
21
+ # `LabelMethodBuilder` wants: a `to_label` walks a singular chain with
22
+ # `public_send`, so a collection mid-path can't yield a scalar value.
23
+ # Display surfaces (index tables, cards, tree) pass `true`: a has_many
24
+ # terminal enum (e.g. `contacts.tier`) resolves to an Array of values
25
+ # that the field def still humanizes element-wise (issue #18). The
26
+ # terminal field def is identical either way — the flag only governs
27
+ # whether a collection mid-path is allowed.
28
+ #
29
+ # Callers treat `[nil, nil]` as "no metadata available — pass through".
30
+ module FieldPath
31
+ module_function
32
+
33
+ def terminal(model_def, path, through_collections: false)
34
+ return [ nil, nil ] if model_def.nil?
35
+
36
+ path_str = path.to_s
37
+ return [ nil, nil ] if path_str.empty?
38
+
39
+ parts = path_str.split(".")
40
+ current_def = model_def
41
+
42
+ parts[0..-2].each do |segment|
43
+ assoc = current_def.associations.find { |a| a.name == segment }
44
+ return [ nil, nil ] unless assoc&.lcp_model?
45
+ return [ nil, nil ] unless through_collections || assoc.singular?
46
+
47
+ current_def = LcpRuby.loader.model_definition(assoc.target_model)
48
+ return [ nil, nil ] if current_def.nil?
49
+ end
50
+
51
+ [ current_def.field(parts.last), current_def.name ]
52
+ rescue MetadataError
53
+ [ nil, nil ]
54
+ end
55
+ end
56
+ end
57
+ end
@@ -701,16 +701,42 @@ module LcpRuby
701
701
  end
702
702
 
703
703
  data = YAML.safe_load_file(menu_file, permitted_classes: [ Symbol, Regexp ])
704
- return unless data
704
+
705
+ # An empty / comment-only menu.yml parses to nil. In :strict mode (no
706
+ # auto-append) that is an empty nav bar with no signal — fail loud,
707
+ # same as a present-but-content-less menu below.
708
+ if data.nil?
709
+ raise_strict_empty_menu! if menu_mode == :strict
710
+ return
711
+ end
705
712
 
706
713
  @menu_definition = MenuDefinition.from_hash(data)
707
714
  validate_menu_references!
708
715
 
716
+ # :strict mode is authoritative and does NOT auto-populate from view
717
+ # groups, so a menu.yml that declares no navigation — neither top_menu
718
+ # nor sidebar_menu, an empty list (`top_menu: []`), or a `responsive:`-
719
+ # only block — would boot with an empty nav bar and no signal. `blank?`
720
+ # catches nil AND []. In :auto mode this is fine: auto-append fills
721
+ # top_menu below.
722
+ if menu_mode == :strict &&
723
+ @menu_definition.top_menu.blank? && @menu_definition.sidebar_menu.blank?
724
+ raise_strict_empty_menu!
725
+ end
726
+
709
727
  auto_append_unreferenced_view_groups! if menu_mode == :auto
710
728
  rescue Psych::SyntaxError => e
711
729
  raise MetadataError, "YAML syntax error in #{menu_file}: #{e.message}"
712
730
  end
713
731
 
732
+ def raise_strict_empty_menu!
733
+ raise MetadataError,
734
+ "menu.yml declares no navigation (neither top_menu nor sidebar_menu) but " \
735
+ "menu_mode is :strict, which does not auto-populate the menu from view groups — " \
736
+ "the app would render with an empty navigation bar. Add a top_menu or sidebar_menu, " \
737
+ "or switch to menu_mode :auto."
738
+ end
739
+
714
740
  def load_theme
715
741
  theme_file = %w[theme.yml theme.yaml].map { |f| base_path.join(f) }.find(&:exist?)
716
742
  return unless theme_file
@@ -774,6 +800,12 @@ module LcpRuby
774
800
 
775
801
  def validate_strict_mode!
776
802
  @view_group_definitions.each_value do |vg|
803
+ # Auto-created VGs (no author-written file) inherit a default
804
+ # `{ menu: "main", position: X }` for `:auto` mode placement.
805
+ # In `:strict` mode that placement is meaningless (menu.yml is
806
+ # authoritative) and there's no file for the configurator to
807
+ # edit — skip them rather than forcing per-VG workaround files.
808
+ next if vg.source_type == "auto"
777
809
  next unless vg.navigable?
778
810
 
779
811
  nav = vg.navigation_config
@@ -1,6 +1,14 @@
1
1
  module LcpRuby
2
2
  module Metadata
3
3
  class MenuDefinition
4
+ # Every recognized top-level key in menu.yml. Mirrors
5
+ # `lib/lcp_ruby/schemas/menu.json` (`additionalProperties: false`).
6
+ # `validate!` rejects anything else so a typo (`top_men:`,
7
+ # `side_menu:`) fails fast at boot instead of silently dropping the
8
+ # whole menu — the same footgun MenuItem.reject_unknown_keys! guards
9
+ # against at the item level.
10
+ KNOWN_TOP_LEVEL_KEYS = %w[top_menu sidebar_menu responsive].freeze
11
+
4
12
  attr_reader :top_menu, :sidebar_menu, :raw_hash, :responsive
5
13
 
6
14
  # Class-level flag so the auto-inject info log fires once per Ruby
@@ -90,9 +98,31 @@ module LcpRuby
90
98
  end
91
99
 
92
100
  def validate!
93
- unless has_top_menu? || has_sidebar_menu?
94
- raise MetadataError, "Menu definition must have at least one of: top_menu, sidebar_menu"
101
+ # Both menus nil is a valid state: a fresh-install menu.yml carries
102
+ # only `# lcp:menu` insertion markers (or a `responsive:`-only block),
103
+ # and in :auto mode auto-append populates top_menu from view groups.
104
+ # A truly empty/comment-only file never reaches here (the loader
105
+ # returns before `from_hash` when YAML parses to nil).
106
+ #
107
+ # But an empty file and a *typo'd* one (`top_men:`/`side_menu:`) used
108
+ # to be indistinguishable — both parse to both-nil and would silently
109
+ # drop a fully-configured menu. Reject unknown top-level keys (with a
110
+ # DidYouMean hint) so the typo fails fast; known-key-only files
111
+ # (including the marker-only fresh install) pass through.
112
+ return if @raw_hash.nil?
113
+
114
+ unknown = @raw_hash.keys.map(&:to_s).reject { |k| KNOWN_TOP_LEVEL_KEYS.include?(k) }
115
+ return if unknown.empty?
116
+
117
+ hint = ""
118
+ if unknown.size == 1
119
+ suggestion = DidYouMean::SpellChecker.new(dictionary: KNOWN_TOP_LEVEL_KEYS).correct(unknown.first).first
120
+ hint = " Did you mean '#{suggestion}:'?" if suggestion
95
121
  end
122
+
123
+ raise MetadataError,
124
+ "Menu has unknown top-level key(s): #{unknown.map { |k| "'#{k}'" }.join(', ')}. " \
125
+ "Valid keys: #{KNOWN_TOP_LEVEL_KEYS.join(', ')}.#{hint}"
96
126
  end
97
127
 
98
128
  def log_auto_inject_sidebar_toggle
@@ -104,10 +104,13 @@ module LcpRuby
104
104
  # Build a MenuItem from a parsed YAML hash.
105
105
  # Detection priority: separator > view_group > children > url|presenter
106
106
  # Existing call style preserved: `from_hash("view_group" => "x", ...)`
107
- # works because the method takes a single positional Hash. Any
108
- # incoming `:type` / `"type"` key is silently ignored type is
109
- # always re-derived from the destination keys present (so merging
110
- # `to_kwargs` with provider returns works without colliding on type).
107
+ # works because the method takes a single positional Hash. `type` is
108
+ # NOT an accepted key here reject_unknown_keys! (in the shared
109
+ # build_from_hash) rejects it, matching the JSON schema, which has no
110
+ # `type` property. Type is always re-derived from the destination keys
111
+ # present. Provider returns that echo a `type:` reach build_from_hash via
112
+ # `from_hash_at_depth` (not this method), but LayoutHelper#filter_provider_keys
113
+ # strips it upstream (PROVIDER_STRIP_KEYS) so the round-trip still works.
111
114
  def self.from_hash(hash)
112
115
  build_from_hash(hash, 0)
113
116
  end
@@ -127,6 +130,31 @@ module LcpRuby
127
130
  # on top in PR #2b's MenuItem extensions.
128
131
  DESTINATION_KEYS = %w[url presenter view_group provider children separator sidebar_toggle].freeze
129
132
 
133
+ # Every key the MenuItem parser knows about. Anything outside this
134
+ # set is rejected at boot — the schema also has `additionalProperties:
135
+ # false` on menu_item but that runs only via `lcp_ruby:validate`, not
136
+ # at `rails s` time. Parse-time rejection turns silent typos
137
+ # (`section: "Marketplace"` instead of `label:`) into immediate boot
138
+ # errors rather than icon-only menu items the configurator hunts for
139
+ # an hour. Keys starting with `_` are reserved for internal DSL
140
+ # annotations (`_label_source_loc`, `_aria_label_source_loc`) and
141
+ # allowed without enumeration.
142
+ # NOTE: `type` is intentionally NOT here. It's an internal kwarg
143
+ # (derived from the destination key, round-tripped via `to_kwargs` →
144
+ # `new` at the Ruby level, never read from author YAML). Listing it
145
+ # would let `type:` pass this parse-time check while the JSON schema
146
+ # (`additionalProperties: false`, no `type` property) rejects it under
147
+ # `lcp_ruby:validate` — two different verdicts for the same file.
148
+ KNOWN_KEYS = %w[
149
+ url presenter view_group provider children separator sidebar_toggle widget
150
+ method alias action defaults
151
+ render render_panel panel_provider options
152
+ label label_key icon aria_label aria_label_key
153
+ visible_when disable_when position badge
154
+ responsive_priority pin hide_below show_below collapse_label_below
155
+ _label_source_loc _aria_label_source_loc
156
+ ].freeze
157
+
130
158
  # Keys allowed on a `:separator` item. Anything else is a configurator
131
159
  # footgun — a separator with a `label:` is invisible (no element renders
132
160
  # it); a separator with `disable_when:` makes no sense (nothing to
@@ -147,6 +175,8 @@ module LcpRuby
147
175
  def self.build_from_hash(hash, depth)
148
176
  hash = HashUtils.stringify_deep(hash)
149
177
 
178
+ reject_unknown_keys!(hash)
179
+
150
180
  # Destination mutex (B1, simple form): exactly one of the
151
181
  # destination keys may be present. (PR #2a doesn't parse
152
182
  # `provider:` — schema rejects unknown keys via
@@ -338,6 +368,25 @@ module LcpRuby
338
368
  end
339
369
  private_class_method :build_from_hash
340
370
 
371
+ # Raise on any key not in KNOWN_KEYS (keys prefixed `_` pass through
372
+ # as internal DSL annotations). When a single unknown key fuzzy-
373
+ # matches a known one (e.g. `section:` → `label:`), include the
374
+ # suggestion in the message — those typos are the common case.
375
+ def self.reject_unknown_keys!(hash)
376
+ unknown = hash.keys.reject { |k| KNOWN_KEYS.include?(k) || k.to_s.start_with?("_") }
377
+ return if unknown.empty?
378
+
379
+ hint = ""
380
+ if unknown.size == 1
381
+ suggestion = DidYouMean::SpellChecker.new(dictionary: KNOWN_KEYS).correct(unknown.first).first
382
+ hint = " Did you mean '#{suggestion}:'?" if suggestion
383
+ end
384
+
385
+ raise MetadataError,
386
+ "Menu item has unknown key(s): #{unknown.map { |k| "'#{k}'" }.join(', ')}.#{hint}"
387
+ end
388
+ private_class_method :reject_unknown_keys!
389
+
341
390
  # Resolve label with i18n support.
342
391
  # Priority: explicit `label: false` short-circuit > label_key >
343
392
  # explicit label > view_group's primary presenter resolved_label >
@@ -368,6 +368,15 @@ module LcpRuby
368
368
  features = []
369
369
  features << "enums" if enum_fields.any?(&:virtual?)
370
370
  features << "service_accessors" if fields.any?(&:service_accessor?)
371
+ # Auto-register the AR :json attribute for json-backed fields. The
372
+ # regular Builder always runs JsonTypeApplicator, but the bind_to path
373
+ # skips the Builder — so without this a bind_to model's json column is
374
+ # left to adapter reflection, and MariaDB (which reports JSON as
375
+ # longtext) returns a raw String instead of a parsed Hash, breaking
376
+ # json_field service accessors and json_valid() inserts.
377
+ features << "json_types" if fields.any? { |f| !f.array? && f.column_type == LcpRuby.json_column_type }
378
+ # Likewise array fields, which are json-backed on non-PostgreSQL adapters.
379
+ features << "array_types" if fields.any?(&:array?)
371
380
  # Auto-enable defaults so bind_to models get the same default-on-create
372
381
  # semantics as dynamic LCP models. Without this, fields like
373
382
  # `theme: enum, default: auto, source: json_field` on a bind_to model
@@ -2,7 +2,12 @@ module LcpRuby
2
2
  module Metadata
3
3
  class ZoneDefinition
4
4
  VALID_AREAS = %w[main tabs sidebar below].freeze
5
- VALID_WIDGET_TYPES = %w[kpi_card text list chart embed workflow_graph approval_status].freeze
5
+ VALID_WIDGET_TYPES = %w[kpi_card text rich_text markdown list chart embed workflow_graph approval_status].freeze
6
+
7
+ # i18n-string widget types — neither depends on a model scope, all
8
+ # require `content_key`, none are valid targets for `depends_on`
9
+ # (no data dependency to refresh on).
10
+ I18N_TEXT_WIDGET_TYPES = %w[text rich_text markdown].freeze
6
11
  VALID_CHART_TYPES = %w[line bar column pie donut area].freeze
7
12
  PIE_CHART_TYPES = %w[pie donut].freeze
8
13
  VALID_CHART_LEGEND_STRINGS = %w[auto top bottom left right].freeze
@@ -281,8 +286,10 @@ module LcpRuby
281
286
  raise MetadataError, "Zone '#{@name}' kpi_card widget requires 'model'" unless @widget["model"]
282
287
  raise MetadataError, "Zone '#{@name}' kpi_card widget requires 'aggregate'" unless @widget["aggregate"]
283
288
  end
284
- when "text"
285
- raise MetadataError, "Zone '#{@name}' text widget requires 'content_key'" unless @widget["content_key"]
289
+ when "text", "rich_text", "markdown"
290
+ unless @widget["content_key"]
291
+ raise MetadataError, "Zone '#{@name}' #{widget_type} widget requires 'content_key'"
292
+ end
286
293
  when "list"
287
294
  raise MetadataError, "Zone '#{@name}' list widget requires 'model'" unless @widget["model"]
288
295
  when "chart"
@@ -372,8 +379,8 @@ module LcpRuby
372
379
  def validate_depends_on!
373
380
  return unless @depends_on
374
381
 
375
- if widget? && @widget && @widget["type"] == "text"
376
- raise MetadataError, "Zone '#{@name}': depends_on is not valid on text widget zones"
382
+ if widget? && @widget && I18N_TEXT_WIDGET_TYPES.include?(@widget["type"])
383
+ raise MetadataError, "Zone '#{@name}': depends_on is not valid on #{@widget["type"]} widget zones"
377
384
  end
378
385
  end
379
386
 
@@ -18,6 +18,11 @@ module LcpRuby
18
18
 
19
19
  if LcpRuby.postgresql?
20
20
  "#{quoted_table}.#{quoted_column} ->> #{conn.quote(key)}"
21
+ elsif LcpRuby.mysql?
22
+ # MySQL/MariaDB JSON_EXTRACT keeps JSON quotes around scalars;
23
+ # JSON_UNQUOTE strips them so comparisons match the raw value. SQLite's
24
+ # json_extract already returns the bare scalar (no JSON_UNQUOTE there).
25
+ "JSON_UNQUOTE(JSON_EXTRACT(#{quoted_table}.#{quoted_column}, #{conn.quote("$.#{key}")}))"
21
26
  else
22
27
  "JSON_EXTRACT(#{quoted_table}.#{quoted_column}, #{conn.quote("$.#{key}")})"
23
28
  end
@@ -12,6 +12,7 @@ module LcpRuby
12
12
  apply_table_name(model_class)
13
13
  apply_enums(model_class)
14
14
  apply_array_types(model_class)
15
+ apply_json_types(model_class)
15
16
  apply_validations(model_class)
16
17
  apply_inherited_parent_validator(model_class)
17
18
  apply_transforms(model_class)
@@ -85,6 +86,10 @@ module LcpRuby
85
86
  ArrayTypeApplicator.new(model_class, model_definition).apply!
86
87
  end
87
88
 
89
+ def apply_json_types(model_class)
90
+ JsonTypeApplicator.new(model_class, model_definition).apply!
91
+ end
92
+
88
93
  def apply_validations(model_class)
89
94
  ValidationApplicator.new(model_class, model_definition).apply!
90
95
  end
@@ -0,0 +1,35 @@
1
+ module LcpRuby
2
+ module ModelFactory
3
+ # Registers an explicit ActiveRecord `:json` attribute type for json-backed
4
+ # fields.
5
+ #
6
+ # PostgreSQL (jsonb) and SQLite (json) are reflected correctly by their
7
+ # adapters, but MariaDB reports JSON columns as `longtext` — so without an
8
+ # explicit type ActiveRecord treats the column as text and serializes a Hash
9
+ # via Ruby's `Hash#to_s` (`{"k"=>"v"}`). That is invalid JSON and violates
10
+ # MariaDB's implicit `json_valid()` CHECK constraint on insert. Declaring the
11
+ # attribute type makes Hash<->JSON (de)serialization adapter-agnostic.
12
+ #
13
+ # Array fields are also json-backed on non-PostgreSQL adapters, but
14
+ # ArrayTypeApplicator already registers their own ArrayType, so they are
15
+ # excluded here. The custom_data column (custom fields) is handled separately
16
+ # in CustomFields::Applicator since it is not part of `model_definition.fields`.
17
+ class JsonTypeApplicator
18
+ def initialize(model_class, model_definition)
19
+ @model_class = model_class
20
+ @model_definition = model_definition
21
+ end
22
+
23
+ def apply!
24
+ @model_definition.fields.each do |field|
25
+ next if field.array?
26
+ next unless field.column_type == LcpRuby.json_column_type
27
+
28
+ options = {}
29
+ options[:default] = field.default unless field.default.nil?
30
+ @model_class.attribute field.name.to_sym, :json, **options
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -55,26 +55,10 @@ module LcpRuby
55
55
 
56
56
  # Walks the dot-path through metadata to locate the terminal field
57
57
  # definition. Returns [FieldDefinition, model_name] tuple, or
58
- # [nil, nil] when any segment cannot be resolved (no model_def,
59
- # non-LCP target, missing association). Callers treat [nil, nil]
60
- # as "no enum routing — pass value through unchanged".
58
+ # [nil, nil] when any segment cannot be resolved. Callers treat
59
+ # [nil, nil] as "no enum routing — pass value through unchanged".
61
60
  def resolve_terminal(model_def, parts)
62
- return [ nil, nil ] if model_def.nil?
63
- return [ nil, nil ] if parts.empty?
64
-
65
- current_def = model_def
66
- parts[0..-2].each do |segment|
67
- assoc = current_def.associations.find { |a| a.name == segment.to_s }
68
- return [ nil, nil ] unless assoc&.singular? && assoc.lcp_model?
69
-
70
- current_def = LcpRuby.loader.model_definition(assoc.target_model)
71
- return [ nil, nil ] if current_def.nil?
72
- end
73
-
74
- terminal = parts.last.to_s
75
- [ current_def.field(terminal), current_def.name ]
76
- rescue MetadataError
77
- [ nil, nil ]
61
+ Metadata::FieldPath.terminal(model_def, parts.join("."))
78
62
  end
79
63
  end
80
64
  end
@@ -10,6 +10,13 @@ module LcpRuby
10
10
  MANAGED_APPLY_MUTEX = Mutex.new
11
11
  private_constant :MANAGED_APPLY_MUTEX
12
12
 
13
+ # Default precision/scale for a `decimal` field that declares neither
14
+ # (via type nor field column_options). Without this MySQL/MariaDB
15
+ # truncates to DECIMAL(10,0). 16 total digits with 4 fractional covers
16
+ # money and rate values with headroom; authors override per field/type.
17
+ DEFAULT_DECIMAL_PRECISION = 16
18
+ DEFAULT_DECIMAL_SCALE = 4
19
+
13
20
  attr_reader :model_definition
14
21
 
15
22
  # `mode` is :full (default) for dynamic models — runs create_table!,
@@ -637,6 +644,18 @@ module LcpRuby
637
644
  options[:precision] = col_opts[:precision] if col_opts[:precision]
638
645
  options[:scale] = col_opts[:scale] if col_opts[:scale]
639
646
  options[:null] = col_opts[:null] if col_opts.key?(:null)
647
+
648
+ # A bare `decimal` truncates on MySQL/MariaDB: their DECIMAL defaults to
649
+ # (10,0) (scale 0), so 19.99 stores as 19. PostgreSQL (unlimited numeric)
650
+ # and SQLite keep the value, so only MySQL needs a default — scope it there
651
+ # to avoid narrowing PG's unlimited precision. Authors override via type or
652
+ # field column_options (e.g. the `currency` type's 12,2); a scale-only
653
+ # override is preserved (fill just the missing precision).
654
+ if field.column_type == :decimal && LcpRuby.mysql? && !options.key?(:precision)
655
+ options[:precision] = DEFAULT_DECIMAL_PRECISION
656
+ options[:scale] = DEFAULT_DECIMAL_SCALE unless options.key?(:scale)
657
+ end
658
+
640
659
  if field.array?
641
660
  if LcpRuby.postgresql?
642
661
  options[:array] = true
@@ -16,6 +16,19 @@ module LcpRuby
16
16
  errors_list << "missing 'name'" unless hash["name"].present?
17
17
  errors_list << "missing 'zones' or empty zones array" unless hash["zones"].is_a?(Array) && hash["zones"].any?
18
18
 
19
+ # Security: the rich_text widget renders its content via `raw()`
20
+ # (unsanitized HTML). That is acceptable for YAML-authored pages
21
+ # (code-reviewed, deployed with the app), but a DB-defined page is
22
+ # end-user-editable through the platform UI — an attacker-controlled
23
+ # `content_key` would round-trip through `I18n.t(..., default:)` and
24
+ # render verbatim, a stored-XSS vector. Forbid it here; the markdown
25
+ # widget (sanitized) covers the same authoring need.
26
+ if hash["zones"].is_a?(Array) &&
27
+ hash["zones"].any? { |z| z.is_a?(Hash) && z["widget"].is_a?(Hash) && z["widget"]["type"] == "rich_text" }
28
+ errors_list << "rich_text widget is not allowed in a database-defined page " \
29
+ "(it renders unsanitized HTML); use the markdown widget instead"
30
+ end
31
+
19
32
  if errors_list.empty?
20
33
  begin
21
34
  LcpRuby::Metadata::PageDefinition.from_hash(hash)