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
@@ -12,20 +12,35 @@ module LcpRuby
12
12
 
13
13
  # Resolve a field value from a record using various path types.
14
14
  #
15
+ # Enum fields return the **raw** stored key (e.g. `"mcp_server"`) by
16
+ # default — not the humanized label. Renderers that need the label
17
+ # read it from `options["label"]`, injected by
18
+ # `DisplayHelper#render_display_value`; the no-renderer display path
19
+ # runs through `format_enum_display`. This separation lets renderers
20
+ # like `badge` look up `color_map[raw_key]` without reverse-mapping
21
+ # a humanized string. (Audit #18.)
22
+ #
23
+ # Callers that want the humanized enum label directly — display
24
+ # templates (`display_template "{status}"`), interpolated template
25
+ # strings — pass `humanize_enums: true`. `resolve_template` does this
26
+ # internally regardless of the keyword passed at the top level,
27
+ # because template interpolation is always a display context.
28
+ #
15
29
  # @param record [ActiveRecord::Base] the record to resolve from
16
30
  # @param field_path [String] field name, dot-path, or template
17
31
  # @param fk_map [Hash] FK field name => AssociationDefinition (for backward compat)
18
- # @param raw [Boolean] when true, return raw enum keys instead of i18n
19
- # labels (used by raw `.json` / `.csv` endpoints).
32
+ # @param humanize_enums [Boolean] when true, route enum-typed terminal
33
+ # values through `Metadata::EnumLabelResolver` (returns the localized
34
+ # label). Default false — renderers and view dispatch get the raw key.
20
35
  # @return [Object, nil] resolved value
21
- def resolve(record, field_path, fk_map: {}, raw: false)
36
+ def resolve(record, field_path, fk_map: {}, humanize_enums: false)
22
37
  field_path = field_path.to_s
23
38
  return nil if field_path.blank?
24
39
 
25
40
  if self.class.template_field?(field_path)
26
41
  resolve_template(record, field_path, fk_map: fk_map)
27
42
  elsif self.class.dot_path?(field_path)
28
- resolve_dot_path(record, field_path, raw: raw)
43
+ resolve_dot_path(record, field_path, humanize_enums: humanize_enums)
29
44
  elsif virtual_column_field?(field_path)
30
45
  resolve_virtual_column(record, field_path)
31
46
  elsif fk_map.key?(field_path)
@@ -33,7 +48,7 @@ module LcpRuby
33
48
  elsif (assoc = model_definition.find_belongs_to(field_path))
34
49
  resolve_association_record(record, assoc.name)
35
50
  else
36
- resolve_simple(record, field_path, raw: raw)
51
+ resolve_simple(record, field_path, humanize_enums: humanize_enums)
37
52
  end
38
53
  end
39
54
 
@@ -60,30 +75,32 @@ module LcpRuby
60
75
  model_definition.name
61
76
  end
62
77
 
78
+ # Template interpolation is always a display context — humanize enums
79
+ # so `"{status}"` renders the localized label, not the raw enum key.
63
80
  def resolve_template(record, template, fk_map: {})
64
81
  template.gsub(/\{([^}]+)\}/) do |_match|
65
82
  ref = Regexp.last_match(1).strip
66
- resolve_ref(record, ref, fk_map: fk_map).to_s
83
+ resolve_ref(record, ref, fk_map: fk_map, humanize_enums: true).to_s
67
84
  end
68
85
  end
69
86
 
70
- def resolve_ref(record, ref, fk_map: {})
87
+ def resolve_ref(record, ref, fk_map: {}, humanize_enums: false)
71
88
  if self.class.dot_path?(ref)
72
- resolve_dot_path(record, ref)
89
+ resolve_dot_path(record, ref, humanize_enums: humanize_enums)
73
90
  elsif fk_map.key?(ref)
74
91
  resolve_fk(record, fk_map[ref])
75
92
  else
76
- resolve_simple(record, ref)
93
+ resolve_simple(record, ref, humanize_enums: humanize_enums)
77
94
  end
78
95
  end
79
96
 
80
- def resolve_dot_path(record, field_path, raw: false)
97
+ def resolve_dot_path(record, field_path, humanize_enums: false)
81
98
  parts = field_path.split(".")
82
99
 
83
100
  # Custom fields use dot-path notation (custom_fields.nickname) but are not associations —
84
101
  # the field name is a dynamic accessor on the record itself.
85
102
  if parts.first == "custom_fields" && parts.length == 2
86
- return resolve_simple(record, parts.last, raw: raw)
103
+ return resolve_simple(record, parts.last, humanize_enums: humanize_enums)
87
104
  end
88
105
 
89
106
  current_record = record
@@ -103,7 +120,7 @@ module LcpRuby
103
120
  end
104
121
 
105
122
  # Terminal field — check permission on current model and read value
106
- return read_terminal_value(current_record, part, current_model_def, raw: raw)
123
+ return read_terminal_value(current_record, part, current_model_def, humanize_enums: humanize_enums)
107
124
  else
108
125
  # Association traversal
109
126
  assoc = current_model_def.associations.find { |a| a.name == part }
@@ -124,7 +141,7 @@ module LcpRuby
124
141
 
125
142
  if assoc.type == "has_many"
126
143
  # For has_many, resolve the remaining path on each related record
127
- return resolve_has_many(current_record, part, parts[(index + 1)..], next_model_def, raw: raw)
144
+ return resolve_has_many(current_record, part, parts[(index + 1)..], next_model_def, humanize_enums: humanize_enums)
128
145
  end
129
146
 
130
147
  # belongs_to / has_one — traverse
@@ -147,7 +164,7 @@ module LcpRuby
147
164
  current
148
165
  end
149
166
 
150
- def resolve_has_many(record, assoc_name, remaining_parts, target_model_def, raw: false)
167
+ def resolve_has_many(record, assoc_name, remaining_parts, target_model_def, humanize_enums: false)
151
168
  return nil unless record.respond_to?(assoc_name)
152
169
 
153
170
  related = record.public_send(assoc_name)
@@ -157,18 +174,18 @@ module LcpRuby
157
174
 
158
175
  related.map do |related_record|
159
176
  sub_resolver = self.class.new(target_model_def, build_evaluator_for(target_model_def.name))
160
- sub_resolver.resolve(related_record, remaining_path, raw: raw)
177
+ sub_resolver.resolve(related_record, remaining_path, humanize_enums: humanize_enums)
161
178
  end.compact
162
179
  end
163
180
 
164
- def read_terminal_value(record, field_name, current_model_def, raw: false)
181
+ def read_terminal_value(record, field_name, current_model_def, humanize_enums: false)
165
182
  return nil if record.nil?
166
183
 
167
184
  evaluator = build_evaluator_for(current_model_def.name)
168
185
  return nil unless evaluator.field_readable?(field_name)
169
186
 
170
187
  value = record.respond_to?(field_name) ? record.public_send(field_name) : nil
171
- return value if raw
188
+ return value unless humanize_enums
172
189
 
173
190
  Metadata::EnumLabelResolver.resolve(value, lookup_field(current_model_def, field_name),
174
191
  model_name: current_model_def.name)
@@ -180,9 +197,9 @@ module LcpRuby
180
197
  self.class.association_label(record.public_send(assoc.name))
181
198
  end
182
199
 
183
- def resolve_simple(record, field_name, raw: false)
200
+ def resolve_simple(record, field_name, humanize_enums: false)
184
201
  value = record.respond_to?(field_name) ? record.public_send(field_name) : nil
185
- return value if raw
202
+ return value unless humanize_enums
186
203
 
187
204
  # `lookup_field` returns nil for unregistered names (e.g., custom
188
205
  # fields), letting EnumLabelResolver pass them through unchanged.
@@ -137,6 +137,8 @@
137
137
  "oneOf": [
138
138
  { "$ref": "#/$defs/kpi_card_widget" },
139
139
  { "$ref": "#/$defs/text_widget" },
140
+ { "$ref": "#/$defs/rich_text_widget" },
141
+ { "$ref": "#/$defs/markdown_widget" },
140
142
  { "$ref": "#/$defs/list_widget" },
141
143
  { "$ref": "#/$defs/chart_widget" },
142
144
  { "$ref": "#/$defs/embed_widget" },
@@ -362,7 +364,7 @@
362
364
 
363
365
  "text_widget": {
364
366
  "type": "object",
365
- "description": "Text widget displaying translated content.",
367
+ "description": "Text widget displaying translated content as plain (HTML-escaped) text. For formatted content, use the rich_text_widget (raw HTML) or markdown_widget (CommonMark) instead.",
366
368
  "properties": {
367
369
  "type": { "type": "string", "const": "text" },
368
370
  "content_key": {
@@ -375,6 +377,36 @@
375
377
  "additionalProperties": false
376
378
  },
377
379
 
380
+ "rich_text_widget": {
381
+ "type": "object",
382
+ "description": "Rich text widget — renders the i18n-resolved content as raw HTML (so the translation can contain `<h2>`, `<a>`, etc.). Trust model: locale files are author-controlled, the same trust level as any engine view using `raw`. When the translation pipeline involves external translators outside code review, prefer the markdown_widget (sanitized output) instead.",
383
+ "properties": {
384
+ "type": { "type": "string", "const": "rich_text" },
385
+ "content_key": {
386
+ "type": "string",
387
+ "description": "i18n key whose value contains HTML.",
388
+ "examples": ["lcp_ruby.pages.home.welcome_html"]
389
+ }
390
+ },
391
+ "required": ["type", "content_key"],
392
+ "additionalProperties": false
393
+ },
394
+
395
+ "markdown_widget": {
396
+ "type": "object",
397
+ "description": "Markdown widget — i18n source is parsed as CommonMark (Commonmarker without `unsafe:`, so raw HTML in the source is stripped at parse time) and the rendered HTML is then sanitized against the same allow-list as the model-level :markdown renderer. Friendlier authoring than HTML for welcome blurbs and dashboard intros.",
398
+ "properties": {
399
+ "type": { "type": "string", "const": "markdown" },
400
+ "content_key": {
401
+ "type": "string",
402
+ "description": "i18n key whose value is Markdown source.",
403
+ "examples": ["lcp_ruby.pages.home.welcome_md"]
404
+ }
405
+ },
406
+ "required": ["type", "content_key"],
407
+ "additionalProperties": false
408
+ },
409
+
378
410
  "list_widget": {
379
411
  "type": "object",
380
412
  "description": "List widget displaying recent records from a model.",
@@ -132,6 +132,11 @@ module LcpRuby
132
132
  prefix = negate ? "NOT " : ""
133
133
  if LcpRuby.postgresql?
134
134
  "#{expr} #{prefix}ILIKE #{conn.quote(pattern)}"
135
+ elsif LcpRuby.mysql?
136
+ # In MySQL/MariaDB string literals the backslash is itself an escape
137
+ # character, so the escape clause needs a doubled backslash ('\\')
138
+ # where PostgreSQL/SQLite accept a single one ('\').
139
+ "#{expr} #{prefix}LIKE #{conn.quote(pattern)} ESCAPE '\\\\'"
135
140
  else
136
141
  "#{expr} #{prefix}LIKE #{conn.quote(pattern)} ESCAPE '\\'"
137
142
  end
@@ -4,20 +4,33 @@ require "fileutils"
4
4
  require_relative "gem_paths"
5
5
 
6
6
  module LcpRuby
7
- # Installs (copies/refreshes) the gem-bundled `lcp-*` Claude Code skills into a
8
- # target `.claude/skills/` directory. Rails-free.
7
+ # Installs (copies/refreshes) the gem-bundled `lcp-*` AI authoring skills into
8
+ # a target agent skills directory (for example `.claude/skills/` or
9
+ # `.codex/skills/`). Rails-free.
9
10
  #
10
11
  # Refresh is an exact mirror of the gem's `lcp-*` set: each shipped skill dir
11
12
  # is replaced wholesale, orphaned `lcp-*` dirs are pruned (when `prune: true`),
12
13
  # and non-`lcp-*` skills are NEVER touched. The installer does no I/O logging —
13
14
  # the caller (CLI prints, generator maps to Thor `say`) reports from {Result}.
14
15
  class SkillsInstaller
16
+ AGENT_DIRECTORIES = {
17
+ "claude" => ".claude",
18
+ "codex" => ".codex"
19
+ }.freeze
20
+ DEFAULT_AGENTS = AGENT_DIRECTORIES.keys.freeze
21
+
15
22
  Result = Struct.new(:created, :updated, :removed, keyword_init: true) do
16
23
  def any_changes?
17
24
  created.any? || updated.any? || removed.any?
18
25
  end
19
26
  end
20
27
 
28
+ def self.agent_skill_targets(root:, agents: DEFAULT_AGENTS)
29
+ agents.each_with_object({}) do |agent, targets|
30
+ targets[agent] = File.join(root, AGENT_DIRECTORIES.fetch(agent), "skills")
31
+ end
32
+ end
33
+
21
34
  def initialize(target:, source: LcpRuby::GemPaths.skills, prune: true)
22
35
  @target = target
23
36
  @source = source
@@ -194,14 +194,19 @@ module LcpRuby
194
194
  end
195
195
  end
196
196
 
197
- skills_dir = File.join(@destination_root, ".claude", "skills")
198
- lcp_skills = Dir.exist?(skills_dir) ? Dir.children(skills_dir).count { |c| c.start_with?("lcp-") } : 0
199
- if lcp_skills.zero?
200
- add_finding(:info, :agent_affordances,
201
- "no lcp-* skills installed in .claude/skills/",
202
- hint: "rails generate lcp_ruby:agent_setup")
203
- else
204
- add_finding(:info, :agent_affordances, "✅ #{lcp_skills} lcp-* skill(s) installed")
197
+ {
198
+ ".claude/skills" => "Claude Code",
199
+ ".codex/skills" => "Codex"
200
+ }.each do |relative_dir, label|
201
+ skills_dir = File.join(@destination_root, relative_dir)
202
+ lcp_skills = Dir.exist?(skills_dir) ? Dir.children(skills_dir).count { |c| c.start_with?("lcp-") } : 0
203
+ if lcp_skills.zero?
204
+ add_finding(:info, :agent_affordances,
205
+ "no lcp-* skills installed in #{relative_dir}/",
206
+ hint: "rails generate lcp_ruby:agent_setup")
207
+ else
208
+ add_finding(:info, :agent_affordances, "✅ #{lcp_skills} #{label} lcp-* skill(s) installed")
209
+ end
205
210
  end
206
211
 
207
212
  # Bundled assets ship with the gem; if these don't resolve the package
@@ -1,3 +1,3 @@
1
1
  module LcpRuby
2
- VERSION = "0.1.0"
2
+ VERSION = "0.2.0"
3
3
  end
@@ -1,3 +1,5 @@
1
+ require "commonmarker"
2
+
1
3
  module LcpRuby
2
4
  module Widgets
3
5
  class DataResolver
@@ -6,13 +8,18 @@ module LcpRuby
6
8
  WidgetScope = Struct.new(:model_class, :model_def, :evaluator, :base_scope, :scope, keyword_init: true)
7
9
 
8
10
  def initialize(zone, user:, scope_context: nil,
9
- filter_form: nil, scope_filter_set: nil, record: nil)
11
+ filter_form: nil, scope_filter_set: nil, record: nil, trusted: true)
10
12
  @zone = zone
11
13
  @user = user
12
14
  @scope_context = scope_context || {}
13
15
  @filter_form = filter_form
14
16
  @scope_filter_set = scope_filter_set
15
17
  @record = record
18
+ # Whether the owning page may emit raw (unsanitized) HTML — false for
19
+ # end-user-editable DB pages, which sanitize rich_text at render.
20
+ # Defaults true so YAML/auto pages and non-page callers keep raw
21
+ # pass-through.
22
+ @trusted = trusted
16
23
  end
17
24
 
18
25
  def resolve
@@ -22,6 +29,8 @@ module LcpRuby
22
29
  case widget["type"]
23
30
  when "kpi_card" then resolve_kpi_card(widget)
24
31
  when "text" then resolve_text(widget)
32
+ when "rich_text" then resolve_rich_text(widget)
33
+ when "markdown" then resolve_markdown(widget)
25
34
  when "list" then resolve_list(widget)
26
35
  when "chart" then resolve_chart(widget)
27
36
  when "embed" then resolve_embed(widget)
@@ -108,6 +117,38 @@ module LcpRuby
108
117
  }
109
118
  end
110
119
 
120
+ # Rich text widget — same i18n content lookup as :text, but the
121
+ # template emits the resolved string as raw HTML (`<h2>`, `<a>`,
122
+ # etc. render as tags). Trust model: `content_key` resolves through
123
+ # author-controlled locale files — same surface as any other engine
124
+ # view that uses `raw`. Parallel to the model-level :rich_text field
125
+ # type. Use :markdown widget instead when the source is author-written
126
+ # markdown — safer XSS surface and friendlier authoring.
127
+ def resolve_rich_text(widget)
128
+ content_key = widget["content_key"]
129
+ {
130
+ content: I18n.t(content_key, default: content_key),
131
+ # On untrusted (DB-defined) pages the partial sanitizes instead of
132
+ # raw()-ing — render-time defense behind the save-time validator.
133
+ trusted: @trusted
134
+ }
135
+ end
136
+
137
+ # Markdown widget — i18n content lookup, then Commonmarker → HTML
138
+ # (sanitize runs in the partial where view_context.sanitize is
139
+ # available). Commonmarker options + the downstream tag/attribute
140
+ # allow-list live on `Display::MarkdownSanitize` so on-page and
141
+ # in-table markdown render identically (same parser opts, same
142
+ # sanitize list). `unsafe:` deliberately omitted — raw HTML in
143
+ # the source is stripped at parse time.
144
+ def resolve_markdown(widget)
145
+ content_key = widget["content_key"]
146
+ source = I18n.t(content_key, default: content_key)
147
+ html = Commonmarker.to_html(source.to_s,
148
+ options: LcpRuby::Display::MarkdownSanitize::COMMONMARKER_OPTIONS)
149
+ { html: html }
150
+ end
151
+
111
152
  def resolve_list(widget)
112
153
  ws = build_widget_scope(widget)
113
154
  return { hidden: true } unless ws
@@ -26,6 +26,8 @@ module LcpRuby
26
26
  def date_trunc_sql
27
27
  if LcpRuby.postgresql?
28
28
  Arel.sql("DATE_TRUNC('#{@period}', #{quoted_field})")
29
+ elsif LcpRuby.mysql?
30
+ mysql_trunc
29
31
  else
30
32
  sqlite_trunc
31
33
  end
@@ -46,6 +48,23 @@ module LcpRuby
46
48
  end
47
49
  end
48
50
 
51
+ # MySQL/MariaDB: DATE_FORMAT yields the same period-key strings the
52
+ # SQLite branch does, so DataResolver's parse/zero-fill stays adapter-agnostic.
53
+ def mysql_trunc
54
+ case @period
55
+ when "quarter"
56
+ Arel.sql("CONCAT(YEAR(#{quoted_field}), '-Q', QUARTER(#{quoted_field}))")
57
+ else
58
+ format = case @period
59
+ when "day" then "%Y-%m-%d"
60
+ when "week" then "%Y-%u"
61
+ when "month" then "%Y-%m"
62
+ when "year" then "%Y"
63
+ end
64
+ Arel.sql("DATE_FORMAT(#{quoted_field}, '#{format}')")
65
+ end
66
+ end
67
+
49
68
  def quoted_field
50
69
  @scope.connection.quote_column_name(@field)
51
70
  end
@@ -3,7 +3,7 @@ module LcpRuby
3
3
  class ContractValidator
4
4
  REQUIRED_FIELDS = {
5
5
  "name" => "string",
6
- "model_name" => "string",
6
+ "target_model" => "string",
7
7
  "field_name" => "string",
8
8
  "states" => "json",
9
9
  "transitions" => "json",
@@ -8,7 +8,7 @@ module LcpRuby
8
8
  return [] unless model_class
9
9
 
10
10
  fields = field_mapping
11
- records = model_class.where(fields["model_name"] => model_name.to_s, fields["active"] => true)
11
+ records = model_class.where(fields["target_model"] => model_name.to_s, fields["active"] => true)
12
12
  records.map { |r| record_to_definition(r) }
13
13
  rescue LcpRuby::Error, ActiveRecord::StatementInvalid => e
14
14
  LcpRuby.record_error(e, subsystem: "workflow", model: model_name.to_s)
@@ -47,7 +47,7 @@ module LcpRuby
47
47
 
48
48
  hash = {
49
49
  "name" => record.send(fields["name"]),
50
- "model" => record.send(fields["model_name"]),
50
+ "model" => record.send(fields["target_model"]),
51
51
  "field" => record.send(fields["field_name"]),
52
52
  "version" => record.send(fields["version"]),
53
53
  "audit_log" => record.respond_to?(fields["audit_log"]) ? record.send(fields["audit_log"]) : false,
data/lib/lcp_ruby.rb CHANGED
@@ -123,6 +123,7 @@ require "lcp_ruby/model_factory/virtual_column_applicator"
123
123
  require "lcp_ruby/model_factory/aggregate_applicator"
124
124
  require "lcp_ruby/model_factory/array_type"
125
125
  require "lcp_ruby/model_factory/array_type_applicator"
126
+ require "lcp_ruby/model_factory/json_type_applicator"
126
127
  require "lcp_ruby/model_factory/workflow_applicator"
127
128
  require "lcp_ruby/model_factory/sequence_applicator"
128
129
  require "lcp_ruby/model_factory/builder"
@@ -335,6 +336,15 @@ module LcpRuby
335
336
  class SchemaError < Error; end
336
337
  class ServiceError < Error; end
337
338
  class ConditionError < Error; end
339
+ # Raised at boot when the host's asset pipeline can't serve LCP's
340
+ # JS/CSS. LCP ships ~50 JS files glued together by Sprockets `//= require`
341
+ # directives in `app/assets/javascripts/lcp_ruby/application.js`. On
342
+ # Rails 8 the default is Propshaft, which doesn't understand those
343
+ # directives — the layout's `<script src=…>` either 404s (Propshaft can't
344
+ # resolve) or returns the 58-line manifest source as-is. Either failure
345
+ # mode is silent (no console errors, no logs), so we fail loud at boot
346
+ # instead. See docs/reference/asset-pipeline.md.
347
+ class AssetPipelineError < Error; end
338
348
  # Raised when path-template resolution fails at render time
339
349
  # (nil mid-chain, missing method on current_user, etc.). Boot-time
340
350
  # template shape errors raise MetadataError instead.
@@ -648,6 +658,14 @@ module LcpRuby
648
658
  ActiveRecord::Base.connection.adapter_name.downcase.include?("postgresql")
649
659
  end
650
660
 
661
+ # True for MySQL and MariaDB (both report adapter_name "Mysql2" via the
662
+ # mysql2 gem, "Trilogy" via trilogy). Used to pick MySQL-flavoured SQL
663
+ # where it differs from SQLite (e.g. DATE_FORMAT vs strftime).
664
+ def mysql?
665
+ name = ActiveRecord::Base.connection.adapter_name.downcase
666
+ name.include?("mysql") || name.include?("trilogy")
667
+ end
668
+
651
669
  def json_column_type
652
670
  postgresql? ? :jsonb : :json
653
671
  end
@@ -69,8 +69,15 @@ end
69
69
  # `db:setup`): `db:setup` invokes `db:seed` as part of its dependency
70
70
  # chain, so a post-action of `db:setup` runs AFTER `db:seed` — too
71
71
  # late if `db:seed` itself needs the tables. Prerequisite of
72
- # `db:seed` covers `db:setup`, `db:reset`, `db:prepare`, and a bare
72
+ # `db:seed` covers `db:setup`, `db:reset`, and a bare
73
73
  # `bin/rails db:seed` uniformly.
74
+ #
75
+ # NOT covered: `db:prepare`. It seeds a freshly-initialized DB via
76
+ # `DatabaseTasks.load_seed` (see Rails' `prepare_all`) — the module
77
+ # method, not the `db:seed` Rake task — so this enhancement never
78
+ # fires. Run `db:setup` (or `db:reset`) when you need the seed-safe
79
+ # path on a fresh DB; `db:prepare` only seeds when it *creates* the DB,
80
+ # so on an existing DB it just migrates and is safe.
74
81
  if Rake::Task.task_defined?("db:seed")
75
82
  Rake::Task["db:seed"].enhance([ "lcp_ruby:ensure_tables" ])
76
83
  end
@@ -18,6 +18,14 @@ end
18
18
  namespace :lcp_ruby do
19
19
  desc "Audit host metadata for missing translations and DSL literals"
20
20
  task :i18n_check, [ :format ] => :environment do |_t, args|
21
+ # `=> :environment` boots Rails autoloaders but does NOT trigger
22
+ # `to_prepare`, so LcpRuby.loader is empty in dev/test invocations.
23
+ # Without these two lines the runner walks zero presenters/pages
24
+ # and reports `0 offense(s)` even when hundreds are missing.
25
+ # Mirrors `lcp_ruby:invariant_check`.
26
+ LcpRuby::Engine.ensure_active_record_loaded!
27
+ LcpRuby::Engine.load_metadata!
28
+
21
29
  exit lcp_ruby_i18n_check_runner_with_host_config.run(format: args[:format])
22
30
  end
23
31
  end