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.
- checksums.yaml +4 -4
- data/.claude/skills/lcp-custom-field/SKILL.md +10 -1
- data/.claude/skills/lcp-custom-field/agents/openai.yaml +4 -0
- data/.claude/skills/lcp-getting-started/SKILL.md +1 -1
- data/.claude/skills/lcp-getting-started/agents/openai.yaml +4 -0
- data/.claude/skills/lcp-host-binding/agents/openai.yaml +4 -0
- data/.claude/skills/lcp-model/agents/openai.yaml +4 -0
- data/.claude/skills/lcp-permissions/agents/openai.yaml +4 -0
- data/.claude/skills/lcp-presenter/agents/openai.yaml +4 -0
- data/.claude/skills/lcp-workflow/SKILL.md +10 -2
- data/.claude/skills/lcp-workflow/agents/openai.yaml +4 -0
- data/CHANGELOG.md +59 -1
- data/app/assets/javascripts/lcp_ruby/dev_toolbar.js +5 -6
- data/app/controllers/concerns/lcp_ruby/zone_resolution.rb +14 -1
- data/app/helpers/lcp_ruby/display/card_helper.rb +71 -16
- data/app/helpers/lcp_ruby/display_helper.rb +30 -3
- data/app/helpers/lcp_ruby/display_template_helper.rb +3 -3
- data/app/helpers/lcp_ruby/layout_helper.rb +49 -8
- data/app/helpers/lcp_ruby/tree_helper.rb +24 -3
- data/app/models/lcp_ruby/user.rb +10 -0
- data/app/views/layouts/lcp_ruby/application.html.erb +42 -20
- data/app/views/layouts/lcp_ruby/auth.html.erb +6 -1
- data/app/views/lcp_ruby/resources/_show_sections.html.erb +24 -17
- data/app/views/lcp_ruby/resources/_table_index.html.erb +12 -17
- data/app/views/lcp_ruby/widgets/_markdown.html.erb +13 -0
- data/app/views/lcp_ruby/widgets/_presenter_zone.html.erb +23 -7
- data/app/views/lcp_ruby/widgets/_rich_text.html.erb +25 -0
- data/docs/README.md +3 -0
- data/docs/feature-catalog.md +5 -2
- data/docs/feature-catalog.yml +89 -9
- data/docs/getting-started.md +38 -22
- data/docs/guides/dashboards.md +44 -1
- data/docs/guides/display-types.md +6 -0
- data/docs/guides/host-application.md +1 -1
- data/docs/guides/windows-setup.md +211 -0
- data/docs/guides/workflow.md +1 -1
- data/docs/reference/asset-pipeline.md +79 -0
- data/docs/reference/pages.md +50 -1
- data/docs/reference/presenters.md +6 -0
- data/docs/reference/workflow.md +2 -2
- data/examples/crm/Gemfile +3 -0
- data/examples/crm/Gemfile.lock +14 -9
- data/examples/crm/app/assets/config/manifest.js +4 -0
- data/examples/hr/Gemfile +3 -0
- data/examples/hr/Gemfile.lock +14 -9
- data/examples/hr/app/assets/config/manifest.js +4 -0
- data/examples/showcase/Gemfile +3 -0
- data/examples/showcase/Gemfile.lock +12 -9
- data/examples/showcase/app/assets/config/manifest.js +4 -0
- data/examples/showcase/config/lcp_ruby/pages/main_dashboard.yml +17 -2
- data/examples/showcase/config/locales/cs.yml +8 -0
- data/examples/showcase/config/locales/en.yml +8 -0
- data/examples/todo/Gemfile +3 -0
- data/examples/todo/Gemfile.lock +14 -9
- data/examples/todo/app/assets/config/manifest.js +4 -0
- data/exe/lcp +1 -1
- data/lib/generators/lcp_ruby/agent_setup_generator.rb +12 -9
- data/lib/generators/lcp_ruby/claude_skills_generator.rb +23 -19
- data/lib/generators/lcp_ruby/install_generator.rb +171 -3
- data/lib/generators/lcp_ruby/templates/agent_setup/agents_md.md +2 -1
- data/lib/generators/lcp_ruby/templates/agent_setup/claude_md.md +3 -2
- data/lib/generators/lcp_ruby/templates/install/menu.yml.tt +4 -0
- data/lib/generators/lcp_ruby/templates/monitoring/page.yml +5 -0
- data/lib/lcp_ruby/app_template.rb +37 -1
- data/lib/lcp_ruby/array_query.rb +33 -10
- data/lib/lcp_ruby/cli/new_command.rb +9 -4
- data/lib/lcp_ruby/cli/run_command.rb +98 -11
- data/lib/lcp_ruby/cli/skills_command.rb +27 -15
- data/lib/lcp_ruby/cli.rb +5 -1
- data/lib/lcp_ruby/configuration.rb +13 -2
- data/lib/lcp_ruby/custom_fields/applicator.rb +8 -0
- data/lib/lcp_ruby/custom_fields/query.rb +28 -20
- data/lib/lcp_ruby/display/markdown_sanitize.rb +36 -0
- data/lib/lcp_ruby/display/renderers/markdown.rb +6 -13
- data/lib/lcp_ruby/engine.rb +185 -1
- data/lib/lcp_ruby/export/data_generator.rb +5 -1
- data/lib/lcp_ruby/export/value_formatter.rb +34 -0
- data/lib/lcp_ruby/grouped_query/builder.rb +21 -0
- data/lib/lcp_ruby/metadata/configuration_validator.rb +5 -5
- data/lib/lcp_ruby/metadata/field_path.rb +57 -0
- data/lib/lcp_ruby/metadata/loader.rb +33 -1
- data/lib/lcp_ruby/metadata/menu_definition.rb +32 -2
- data/lib/lcp_ruby/metadata/menu_item.rb +53 -4
- data/lib/lcp_ruby/metadata/model_definition.rb +9 -0
- data/lib/lcp_ruby/metadata/zone_definition.rb +12 -5
- data/lib/lcp_ruby/metrics/json_query.rb +5 -0
- data/lib/lcp_ruby/model_factory/builder.rb +5 -0
- data/lib/lcp_ruby/model_factory/json_type_applicator.rb +35 -0
- data/lib/lcp_ruby/model_factory/label_method_builder.rb +3 -19
- data/lib/lcp_ruby/model_factory/schema_manager.rb +19 -0
- data/lib/lcp_ruby/pages/definition_validator.rb +13 -0
- data/lib/lcp_ruby/presenter/field_value_resolver.rb +36 -19
- data/lib/lcp_ruby/schemas/page.json +33 -1
- data/lib/lcp_ruby/search/custom_field_filter.rb +5 -0
- data/lib/lcp_ruby/skills_installer.rb +15 -2
- data/lib/lcp_ruby/tasks/doctor.rb +13 -8
- data/lib/lcp_ruby/version.rb +1 -1
- data/lib/lcp_ruby/widgets/data_resolver.rb +42 -1
- data/lib/lcp_ruby/widgets/date_grouper.rb +19 -0
- data/lib/lcp_ruby/workflow/contract_validator.rb +1 -1
- data/lib/lcp_ruby/workflow/model_source.rb +2 -2
- data/lib/lcp_ruby.rb +18 -0
- data/lib/tasks/lcp_ruby_db.rake +8 -1
- data/lib/tasks/lcp_ruby_i18n_check.rake +8 -0
- metadata +34 -24
- data/examples/crm/erd.md +0 -163
- data/examples/hr/erd.md +0 -396
- data/examples/showcase/erd.md +0 -567
- 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
|
|
19
|
-
#
|
|
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: {},
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
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,
|
|
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
|
|
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-*`
|
|
8
|
-
# target `.claude/skills/`
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
data/lib/lcp_ruby/version.rb
CHANGED
|
@@ -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
|
|
@@ -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["
|
|
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["
|
|
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
|
data/lib/tasks/lcp_ruby_db.rake
CHANGED
|
@@ -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`,
|
|
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
|