ruact 0.0.5 → 0.0.7

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 (135) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +54 -2
  3. data/.rubocop_todo.yml +3 -115
  4. data/CHANGELOG.md +74 -18
  5. data/bench/server_functions_dispatch_bench.rb +109 -142
  6. data/bench/server_functions_dispatch_bench.results.md +29 -0
  7. data/docs/internal/decisions/server-functions-api.md +402 -0
  8. data/lib/generators/ruact/install/install_generator.rb +310 -25
  9. data/lib/generators/ruact/install/templates/Procfile.dev.tt +2 -0
  10. data/lib/generators/ruact/install/templates/dev.tt +16 -0
  11. data/lib/generators/ruact/install/templates/package.json.tt +17 -0
  12. data/lib/generators/ruact/install/templates/vite.config.js.tt +3 -1
  13. data/lib/generators/ruact/scaffold/scaffold_attribute.rb +114 -0
  14. data/lib/generators/ruact/scaffold/scaffold_form_helpers.rb +114 -0
  15. data/lib/generators/ruact/scaffold/scaffold_generator.rb +491 -0
  16. data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +229 -0
  17. data/lib/generators/ruact/scaffold/templates/components/DeleteDialog.tsx.tt +104 -0
  18. data/lib/generators/ruact/scaffold/templates/components/Form.tsx.tt +212 -0
  19. data/lib/generators/ruact/scaffold/templates/components/List.tsx.tt +338 -0
  20. data/lib/generators/ruact/scaffold/templates/components/agnostic/DeleteDialog.tsx.tt +92 -0
  21. data/lib/generators/ruact/scaffold/templates/components/agnostic/Form.tsx.tt +174 -0
  22. data/lib/generators/ruact/scaffold/templates/components/agnostic/List.tsx.tt +253 -0
  23. data/lib/generators/ruact/scaffold/templates/controller.rb.tt +130 -0
  24. data/lib/generators/ruact/scaffold/templates/queries/application_query.rb.tt +13 -0
  25. data/lib/generators/ruact/scaffold/templates/queries/query.rb.tt +59 -0
  26. data/lib/generators/ruact/scaffold/templates/request_spec.rb.tt +77 -0
  27. data/lib/generators/ruact/scaffold/templates/views/edit.html.erb.tt +7 -0
  28. data/lib/generators/ruact/scaffold/templates/views/index.html.erb.tt +6 -0
  29. data/lib/generators/ruact/scaffold/templates/views/new.html.erb.tt +5 -0
  30. data/lib/generators/ruact/scaffold/templates/views/show.html.erb.tt +16 -0
  31. data/lib/ruact/client_manifest.rb +37 -36
  32. data/lib/ruact/component_contract.rb +115 -0
  33. data/lib/ruact/configuration.rb +80 -28
  34. data/lib/ruact/controller.rb +79 -425
  35. data/lib/ruact/doctor.rb +133 -4
  36. data/lib/ruact/erb_preprocessor.rb +71 -12
  37. data/lib/ruact/erb_preprocessor_hook.rb +4 -1
  38. data/lib/ruact/errors.rb +30 -44
  39. data/lib/ruact/html_converter.rb +22 -1
  40. data/lib/ruact/manifest_resolver.rb +149 -0
  41. data/lib/ruact/railtie.rb +70 -203
  42. data/lib/ruact/render_pipeline.rb +8 -1
  43. data/lib/ruact/server.rb +28 -6
  44. data/lib/ruact/server_functions/codegen.rb +49 -188
  45. data/lib/ruact/server_functions/codegen_v2.rb +23 -6
  46. data/lib/ruact/server_functions/codegen_v2_query_params.rb +140 -0
  47. data/lib/ruact/server_functions/error_payload.rb +1 -1
  48. data/lib/ruact/server_functions/error_rendering.rb +22 -29
  49. data/lib/ruact/server_functions/name_bridge.rb +14 -16
  50. data/lib/ruact/server_functions/query_source.rb +35 -8
  51. data/lib/ruact/server_functions/route_source.rb +3 -4
  52. data/lib/ruact/server_functions/snapshot.rb +12 -139
  53. data/lib/ruact/server_functions/validation_errors.rb +70 -0
  54. data/lib/ruact/server_functions.rb +21 -25
  55. data/lib/ruact/signed_references.rb +162 -0
  56. data/lib/ruact/string_distance.rb +72 -0
  57. data/lib/ruact/validation_errors_collector.rb +139 -0
  58. data/lib/ruact/version.rb +1 -1
  59. data/lib/ruact/view_helper.rb +109 -0
  60. data/lib/ruact.rb +20 -19
  61. data/lib/tasks/ruact.rake +10 -53
  62. data/spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb +3 -0
  63. data/spec/ruact/client_manifest_spec.rb +36 -0
  64. data/spec/ruact/component_contract_spec.rb +119 -0
  65. data/spec/ruact/configuration_spec.rb +51 -34
  66. data/spec/ruact/controller_request_spec.rb +264 -0
  67. data/spec/ruact/controller_spec.rb +70 -328
  68. data/spec/ruact/doctor_spec.rb +201 -0
  69. data/spec/ruact/erb_preprocessor_hook_spec.rb +4 -1
  70. data/spec/ruact/erb_preprocessor_spec.rb +127 -0
  71. data/spec/ruact/errors_spec.rb +0 -45
  72. data/spec/ruact/html_converter_spec.rb +50 -0
  73. data/spec/ruact/install_generator_spec.rb +591 -4
  74. data/spec/ruact/manifest_resolver_spec.rb +159 -0
  75. data/spec/ruact/query_request_spec.rb +109 -1
  76. data/spec/ruact/scaffold_generator_spec.rb +1835 -0
  77. data/spec/ruact/server_bucket_request_spec.rb +142 -0
  78. data/spec/ruact/server_function_name_spec.rb +1 -1
  79. data/spec/ruact/server_functions/codegen_spec.rb +158 -269
  80. data/spec/ruact/server_functions/name_bridge_spec.rb +9 -9
  81. data/spec/ruact/server_functions/query_source_spec.rb +51 -0
  82. data/spec/ruact/server_functions/railtie_integration_spec.rb +71 -268
  83. data/spec/ruact/server_functions/rake_spec.rb +29 -29
  84. data/spec/ruact/server_functions/snapshot_spec.rb +53 -213
  85. data/spec/ruact/server_rescue_request_spec.rb +6 -6
  86. data/spec/ruact/server_spec.rb +8 -9
  87. data/spec/ruact/signed_references_spec.rb +164 -0
  88. data/spec/ruact/string_distance_spec.rb +38 -0
  89. data/spec/ruact/validation_errors_spec.rb +116 -0
  90. data/spec/ruact/view_helper_spec.rb +79 -0
  91. data/spec/spec_helper.rb +15 -5
  92. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +40 -45
  93. data/vendor/javascript/ruact-server-functions-runtime/index.js +42 -98
  94. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +72 -75
  95. data/vendor/javascript/ruact-server-functions-runtime/package.json +1 -1
  96. data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +96 -0
  97. data/vendor/javascript/vite-plugin-ruact/index.js +372 -6
  98. data/vendor/javascript/vite-plugin-ruact/manifest-contract.test.mjs +182 -0
  99. data/vendor/javascript/vite-plugin-ruact/manifest-http.test.mjs +125 -0
  100. data/vendor/javascript/vite-plugin-ruact/package-lock.json +16 -0
  101. data/vendor/javascript/vite-plugin-ruact/package.json +3 -1
  102. data/vendor/javascript/vite-plugin-ruact/registry.test.mjs +199 -0
  103. data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +68 -0
  104. data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +235 -0
  105. data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +473 -0
  106. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +114 -139
  107. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +199 -262
  108. data/vendor/javascript/vite-plugin-ruact/tsconfig.json +18 -0
  109. data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold-agnostic.json +18 -0
  110. data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold.json +20 -0
  111. data/vendor/javascript/vite-plugin-ruact/type-tests/emitted-module.test-d.ts +23 -0
  112. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostDeleteDialog.tsx +90 -0
  113. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostForm.tsx +238 -0
  114. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostList.tsx +339 -0
  115. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostDeleteDialog.tsx +85 -0
  116. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostForm.tsx +216 -0
  117. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostList.tsx +269 -0
  118. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/ambient.d.ts +78 -0
  119. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/ambient.d.ts +166 -0
  120. data/vendor/javascript/vite-plugin-ruact/type-tests/typed-query.test-d.ts +48 -0
  121. data/vendor/javascript/vite-plugin-ruact/type-tests/usequery.test-d.ts +26 -0
  122. metadata +58 -15
  123. data/lib/generators/ruact/install/templates/application.jsx.tt +0 -51
  124. data/lib/ruact/server_action.rb +0 -131
  125. data/lib/ruact/server_functions/endpoint_controller.rb +0 -237
  126. data/lib/ruact/server_functions/registry.rb +0 -148
  127. data/lib/ruact/server_functions/registry_entry.rb +0 -26
  128. data/lib/ruact/server_functions/standalone_context.rb +0 -103
  129. data/lib/ruact/server_functions/standalone_dispatcher.rb +0 -178
  130. data/spec/ruact/server_functions/csrf_request_spec.rb +0 -380
  131. data/spec/ruact/server_functions/dispatch_request_spec.rb +0 -819
  132. data/spec/ruact/server_functions/registry_spec.rb +0 -199
  133. data/spec/ruact/server_functions/standalone_action_spec.rb +0 -224
  134. data/spec/ruact/server_functions/standalone_context_spec.rb +0 -142
  135. data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +0 -273
@@ -19,6 +19,24 @@ module Ruact
19
19
  # "name": "default"
20
20
  # }
21
21
  # }
22
+ #
23
+ # Story 13.5 (FR100) — an entry MAY carry an optional, purely additive
24
+ # +contract+ field when the component opts in by exporting +__ruactContract+
25
+ # from its +.tsx+ (the Vite plugin extracts it names-only). Shape:
26
+ #
27
+ # "LikeButton": {
28
+ # "id": ..., "name": ..., "chunks": [...],
29
+ # "contract": {
30
+ # "props": { "postId" => "required", "initialCount" => "optional" },
31
+ # "slots": { "header" => "optional" }, # optional
32
+ # "passthrough": false # optional
33
+ # }
34
+ # }
35
+ #
36
+ # A component without the export has NO +contract+ key (back-compatible: every
37
+ # existing manifest + reader is unaffected). {#contract_for} reads it for the
38
+ # Story 13.5 preprocess-time call-site validator; +nil+ means "no contract →
39
+ # no validation" (fail open).
22
40
  class ClientManifest
23
41
  # Used by Flight::Serializer to produce I rows.
24
42
  # Returns the metadata array the client expects: [id, name, chunks]
@@ -62,6 +80,24 @@ module Ruact
62
80
  end
63
81
  end
64
82
 
83
+ # Story 13.5 (FR100) — return the optional component +contract+ Hash for
84
+ # +name+, or +nil+ when the component declared none (or is absent from the
85
+ # manifest). Honors the same co-located/shared +resolve_key+ precedence as
86
+ # {#reference_for}, so a co-located component's contract is found when the
87
+ # call site's +controller_path+ is known. A pure read (no memoization, no
88
+ # mutation) — safe on the frozen manifest, never raises for an unknown name
89
+ # (the validator fails open). Consumed by {Ruact::ComponentContract} from
90
+ # the ERB preprocessor.
91
+ #
92
+ # @param name [String] PascalCase component name (e.g. "LikeButton")
93
+ # @param controller_path [String, nil] e.g. "posts" — biases toward a
94
+ # co-located key ("posts/_like_button") when present.
95
+ # @return [Hash, nil] the contract Hash, or nil when none is declared.
96
+ def contract_for(name, controller_path: nil)
97
+ entry = entries_by_name[resolve_key(name, controller_path)]
98
+ entry && entry["contract"]
99
+ end
100
+
65
101
  # Load from a file path (JSON).
66
102
  # Pre-warms the reference cache and freezes the manifest so it cannot be
67
103
  # mutated at runtime (AC#5). Pre-warming is required because Ruby's freeze
@@ -131,7 +167,7 @@ module Ruact
131
167
 
132
168
  pool.each do |key|
133
169
  comparable = comparable_name_for(key).downcase
134
- distance = self.class.send(:damerau_levenshtein_distance, target, comparable)
170
+ distance = StringDistance.damerau_levenshtein(target, comparable)
135
171
  next if distance > 2
136
172
 
137
173
  in_scope = controller_path && key.start_with?("#{controller_path}/")
@@ -155,41 +191,6 @@ module Ruact
155
191
  basename.split("_").map(&:capitalize).join
156
192
  end
157
193
 
158
- # Damerau-Levenshtein distance — like classic Levenshtein but treats
159
- # an adjacent transposition (e.g. "ke"↔"ek") as a single edit. Component
160
- # names are short (≤ 30 chars in practice) so the full O(m·n) DP table
161
- # is fine; the readability win over the two-row Levenshtein trick is
162
- # worth the extra ~30 cells of allocation in the failure path.
163
- # rubocop:disable Metrics/AbcSize
164
- def self.damerau_levenshtein_distance(left, right)
165
- return right.length if left.empty?
166
- return left.length if right.empty?
167
-
168
- m = left.length
169
- n = right.length
170
- d = Array.new(m + 1) { Array.new(n + 1, 0) }
171
- (0..m).each { |i| d[i][0] = i }
172
- (0..n).each { |j| d[0][j] = j }
173
-
174
- (1..m).each do |i|
175
- (1..n).each do |j|
176
- cost = left[i - 1] == right[j - 1] ? 0 : 1
177
- d[i][j] = [
178
- d[i - 1][j] + 1, # deletion
179
- d[i][j - 1] + 1, # insertion
180
- d[i - 1][j - 1] + cost # substitution
181
- ].min
182
- if i > 1 && j > 1 && left[i - 1] == right[j - 2] && left[i - 2] == right[j - 1]
183
- d[i][j] = [d[i][j], d[i - 2][j - 2] + cost].min
184
- end
185
- end
186
- end
187
-
188
- d[m][n]
189
- end
190
- # rubocop:enable Metrics/AbcSize
191
- private_class_method :damerau_levenshtein_distance
192
-
193
194
  # Returns the manifest key to use for +name+ given an optional +controller_path+.
194
195
  # Co-located key format: "<controller_path>/_<underscored_name>" (e.g. "posts/_like_button").
195
196
  # Co-located takes precedence when both keys exist.
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruact
4
+ # Story 13.5 (FR100) — compile-time component contract (HEEx-style `attr`/
5
+ # `slot`). Validates a `<Component .../>` ERB call site against the
6
+ # component's OPT-IN contract at preprocess time, so a missing required prop,
7
+ # a typo'd prop name, or a missing required slot surfaces an error at the call
8
+ # site (file:line) BEFORE the page renders — not as a silent `undefined` in
9
+ # the browser.
10
+ #
11
+ # This is the CONSUMER-side mirror of `ruact_props` (which validates the
12
+ # PRODUCER side at class-load, +serializable.rb+). It is NAME-level only:
13
+ # prop VALUES are arbitrary render-time Ruby expressions the preprocessor
14
+ # never evaluates, so only names + presence + slots are checkable here (value
15
+ # typing for the server boundary already landed in Story 13.4).
16
+ #
17
+ # The contract Hash is the one the Vite plugin extracted into the manifest:
18
+ #
19
+ # {
20
+ # "props" => { "postId" => "required", "initialCount" => "optional" },
21
+ # "slots" => { "header" => "optional" }, # optional
22
+ # "passthrough" => false # optional
23
+ # }
24
+ #
25
+ # Pure + stateless (explicit class methods) so it is trivially testable in
26
+ # isolation and carries no global state.
27
+ module ComponentContract
28
+ # Validate a call site's prop NAMES against +contract+. Raises
29
+ # {Ruact::ComponentContractError} (a {Ruact::PreprocessorError}) on the
30
+ # first violation. A +nil+ contract is a no-op (fail open / opt-in).
31
+ #
32
+ # @param component_name [String] e.g. "LikeButton"
33
+ # @param prop_names [Array<String>] names parsed from the call site (e.g.
34
+ # `["postId", "initialCount"]` for a `<LikeButton>` tag with those props)
35
+ # @param contract [Hash, nil] the manifest contract Hash, or nil
36
+ # @param at [Hash] call-site location for the message with optional keys
37
+ # +file+ (from `template.identifier`), +line+ (the 1-based call-site
38
+ # line), and +snippet+ (the offending tag text)
39
+ # @return [void]
40
+ # @raise [Ruact::ComponentContractError] on a contract violation
41
+ def self.validate(component_name:, prop_names:, contract:, at: {})
42
+ return if contract.nil?
43
+
44
+ props = normalize(contract["props"])
45
+ slots = normalize(contract["slots"])
46
+ given = Array(prop_names).map(&:to_s)
47
+ where = { component: component_name, file: at[:file], line: at[:line], snippet: at[:snippet] }
48
+ unknown = contract["passthrough"] == true ? [] : (given - (props.keys + slots.keys))
49
+
50
+ # Priority order so the highest-signal message wins. A near-miss typo is
51
+ # reported FIRST even when it leaves a required prop "missing" — e.g.
52
+ # `<LikeButton postID={1} />` (postID typo of the required postId) should
53
+ # say "unknown prop \"postID\" — did you mean \"postId\"?", not the less
54
+ # helpful "missing required prop \"postId\"" (FR100's canonical typo case).
55
+ check_typo(unknown, props.keys + slots.keys, where: where)
56
+ check_missing_required(props, given, kind: "prop", where: where)
57
+ check_missing_required(slots, given, kind: "slot", where: where)
58
+ check_unknown(unknown.first, where: where) if unknown.any?
59
+ end
60
+
61
+ # Normalize a contract sub-section into a { "name" => "required"|"optional" }
62
+ # Hash. Accepts the manifest's Hash form, an Array (all optional — the JS
63
+ # extractor already collapses array slots to a Hash, but be defensive), or
64
+ # nil/garbage (→ {}).
65
+ def self.normalize(section)
66
+ case section
67
+ when Hash then section
68
+ when Array then section.to_h { |name| [name.to_s, "optional"] }
69
+ else {}
70
+ end
71
+ end
72
+
73
+ # An unknown prop whose closest declared match is within edit distance is a
74
+ # likely typo — report it (with the suggestion) ahead of any missing-required
75
+ # message. Returns/raises on the first such prop; a no-op when none qualify.
76
+ def self.check_typo(unknown, declared, where:)
77
+ unknown.each do |name|
78
+ suggestion = StringDistance.closest_match(name, declared)
79
+ next unless suggestion
80
+
81
+ raise_error("got unknown prop #{name.inspect} — did you mean #{suggestion.inspect}?", where)
82
+ end
83
+ end
84
+
85
+ def self.check_missing_required(spec, given, kind:, where:)
86
+ required = spec.select { |_, requiredness| requiredness == "required" }.keys
87
+ missing = required - given
88
+ return if missing.empty?
89
+
90
+ name = missing.first
91
+ raise_error("is missing required #{kind} #{name.inspect} — add the required #{kind} #{name.inspect}", where)
92
+ end
93
+
94
+ def self.check_unknown(name, where:)
95
+ raise_error("got unknown prop #{name.inspect}", where)
96
+ end
97
+
98
+ # Build + raise the {Ruact::ComponentContractError} with the full message:
99
+ # component name + file:line + the offending prop/slot + the corrective
100
+ # suggestion. The message is self-contained (the preprocessor re-raises it
101
+ # as-is, no generic line/snippet tail appended).
102
+ #
103
+ # @param detail [String] the violation clause
104
+ # @param where [Hash] keys +component+, +file+, +line+, +snippet+
105
+ def self.raise_error(detail, where)
106
+ location = [where[:file], where[:line]].compact.join(":")
107
+ location = "(unknown location)" if location.empty?
108
+ message = "ruact: <#{where[:component]}> at #{location} #{detail}."
109
+ message += "\n in: #{where[:snippet]}" if where[:snippet] && !where[:snippet].empty?
110
+ raise ComponentContractError, message
111
+ end
112
+
113
+ private_class_method :normalize, :check_typo, :check_missing_required, :check_unknown, :raise_error
114
+ end
115
+ end
@@ -17,11 +17,13 @@ module Ruact
17
17
  strict_serialization
18
18
  suspense_timeout
19
19
  vite_dev_server
20
- current_user_resolver
21
20
  dev_error_payload_enabled
22
21
  max_upload_bytes
23
22
  query_route_prefix
24
23
  query_parent_controller
24
+ signed_global_id_default_purpose
25
+ signed_global_id_default_expires_in
26
+ shadcn_compatible_versions
25
27
  ].freeze
26
28
 
27
29
  # @!attribute [r] manifest_path
@@ -38,17 +40,6 @@ module Ruact
38
40
  # @!attribute [r] vite_dev_server
39
41
  # @return [String] Base URL of the Vite dev server. Default: "http://localhost:5173".
40
42
  #
41
- # @!attribute [r] current_user_resolver
42
- # @return [Proc, nil] Story 8.3 — Lambda invoked by the standalone
43
- # server-action dispatcher when a block reads `current_user`. Receives
44
- # `request.env` (Hash) and returns the authenticated user (or nil).
45
- # Memoized per-dispatch; left nil by default so apps that don't use
46
- # standalone actions never get a phantom `current_user` resolver.
47
- # @example Devise
48
- # Ruact.configure { |c| c.current_user_resolver = ->(env) { env['warden']&.user } }
49
- # @example Hand-rolled session
50
- # Ruact.configure { |c| c.current_user_resolver = ->(env) { User.find_by(id: env['rack.session'][:user_id]) } }
51
- #
52
43
  # @!attribute [r] dev_error_payload_enabled
53
44
  # @return [Boolean, nil] Story 8.4 — When true, server-action failures
54
45
  # respond with a verbose JSON payload (action name, error class,
@@ -56,8 +47,8 @@ module Ruact
56
47
  # When false, the wire body carries only the four baseline fields
57
48
  # (`_ruact_server_action_error`, `action_name`, `error_class`,
58
49
  # `message`) so React components can render their own UI without
59
- # accidental backtrace leakage. Default `nil` — the endpoint
60
- # controller resolves nil to `Rails.env.development? || Rails.env.test?`,
50
+ # accidental backtrace leakage. Default `nil` — the error-rendering
51
+ # layer resolves nil to `Rails.env.development? || Rails.env.test?`,
61
52
  # keeping the Configuration trivially constructible in non-Rails specs.
62
53
  # @example Force production-shape errors in development
63
54
  # Ruact.configure { |c| c.dev_error_payload_enabled = false }
@@ -65,9 +56,9 @@ module Ruact
65
56
  # @!attribute [r] max_upload_bytes
66
57
  # @return [Integer, nil] Story 8.5 — upper bound (in bytes) on the
67
58
  # `Content-Length` of `multipart/form-data` and
68
- # `application/x-www-form-urlencoded` requests dispatched through
69
- # `POST /__ruact/fn/:name`. When the inbound `Content-Length` exceeds
70
- # this value, the endpoint controller raises
59
+ # `application/x-www-form-urlencoded` requests dispatched to a
60
+ # `Ruact::Server` mutation route. When the inbound `Content-Length`
61
+ # exceeds this value, the server concern raises
71
62
  # `Ruact::UploadTooLargeError` BEFORE Rack's multipart parser runs,
72
63
  # producing a 413 with the Story 8.4 structured error body.
73
64
  # Default: `10 * 1024 * 1024` (10 MB). Set to `nil` to disable the
@@ -105,6 +96,45 @@ module Ruact
105
96
  # scoping, Pundit) runs before any query class is instantiated (FR89).
106
97
  # @example Dispatch queries through an API base controller
107
98
  # Ruact.configure { |c| c.query_parent_controller = "Api::BaseController" }
99
+ #
100
+ # @!attribute [r] signed_global_id_default_purpose
101
+ # @return [Symbol, String, nil] Story 13.2 (FR96) — the default `for:`
102
+ # purpose `Ruact.signed_global_id` / `Ruact.locate_signed` use when the
103
+ # call omits `for:`. A purpose scopes a signed reference to one
104
+ # use-site so a token minted for editing a post cannot be replayed
105
+ # against, say, a delete endpoint. Default `nil` — when neither the
106
+ # call nor this config supplies a purpose, the helper raises
107
+ # `Ruact::Error` rather than sign an unscoped token (the "developer
108
+ # forgot" path is a loud error, never a silent insecure default).
109
+ # Prefer a per-call `for:` when use-sites differ; set this only for an
110
+ # app-wide default purpose.
111
+ # @example Set an app-wide default purpose
112
+ # Ruact.configure { |c| c.signed_global_id_default_purpose = :ruact_ref }
113
+ #
114
+ # @!attribute [r] signed_global_id_default_expires_in
115
+ # @return [ActiveSupport::Duration, nil] Story 13.2 (FR96) — the default
116
+ # `expires_in:` `Ruact.signed_global_id` uses when the call omits
117
+ # `expires_in:`. Must be an `ActiveSupport::Duration` (e.g. `15.minutes`)
118
+ # — globalid calls `#from_now` on it. Default `nil` — when neither the
119
+ # call nor this config supplies an expiry, the helper raises
120
+ # `Ruact::Error` rather than mint a non-expiring token. To deliberately
121
+ # mint a non-expiring token, pass an explicit `expires_in: nil` at the
122
+ # call site (a reviewed per-call choice), never via this default.
123
+ # @example Set an app-wide default expiry
124
+ # Ruact.configure { |c| c.signed_global_id_default_expires_in = 1.hour }
125
+ #
126
+ # @!attribute [r] shadcn_compatible_versions
127
+ # @return [Array<Integer>] Story 10.5 — the shadcn/ui MAJOR versions the
128
+ # `ruact:scaffold` generator is regression-tested against. When the
129
+ # generator detects an installed shadcn major (best-effort, from the
130
+ # host `package.json`) that is NOT in this list, it emits a warning
131
+ # (never a hard stop) that the generated components may import from
132
+ # outdated `@/components/ui/*` paths. Must be a non-empty Array of
133
+ # Integers. Default `[1, 2]` (the majors tested at gem-release time).
134
+ # A dev who has manually verified a newer major adds it here to
135
+ # suppress the warning — the documented "override" path.
136
+ # @example Allow shadcn v3 once you have verified it
137
+ # Ruact.configure { |c| c.shadcn_compatible_versions = [1, 2, 3] }
108
138
  ATTRIBUTES.each do |attr|
109
139
  attr_reader attr
110
140
 
@@ -135,11 +165,10 @@ module Ruact
135
165
  if template
136
166
  ATTRIBUTES.each do |attr|
137
167
  value = template.public_send(attr)
138
- # Procs are immutable from the outside (Story 8.3 current_user_resolver).
139
- # Duping creates a different Proc instance, breaking identity comparisons
140
- # across re-configurations. Procs are inherently re-entrant safe (no
141
- # mutable internal state surface) so the dup is unnecessary; the freeze
142
- # at seal! time is enough.
168
+ # Procs are immutable from the outside. Duping creates a different Proc
169
+ # instance, breaking identity comparisons across re-configurations.
170
+ # Procs are inherently re-entrant safe (no mutable internal state
171
+ # surface) so the dup is unnecessary; the freeze at seal! time is enough.
143
172
  cloned = value.is_a?(Proc) ? value : value.dup
144
173
  instance_variable_set("@#{attr}", cloned)
145
174
  end
@@ -152,11 +181,13 @@ module Ruact
152
181
  end
153
182
  @suspense_timeout = 5.0
154
183
  @vite_dev_server = "http://localhost:5173"
155
- @current_user_resolver = nil
156
184
  @dev_error_payload_enabled = nil
157
185
  @max_upload_bytes = 10 * 1024 * 1024
158
186
  @query_route_prefix = "/q"
159
187
  @query_parent_controller = "ApplicationController"
188
+ @signed_global_id_default_purpose = nil
189
+ @signed_global_id_default_expires_in = nil
190
+ @shadcn_compatible_versions = [1, 2]
160
191
  end
161
192
  end
162
193
 
@@ -186,11 +217,10 @@ module Ruact
186
217
 
187
218
  # Story 8.3 review — Procs CAN be frozen (`.freeze` flips the frozen
188
219
  # flag; no operational effect on `.call`). Freezing in place preserves
189
- # object identity (vital for code that compares the resolver across
190
- # re-configurations) AND keeps the deep-freeze contract honest —
191
- # `Ruact.config.current_user_resolver.freeze` later would otherwise
192
- # silently no-op an already-frozen reference, but a caller probing
193
- # `frozen?` would see the right answer.
220
+ # object identity (vital for code that compares a Proc-valued attribute
221
+ # across re-configurations) AND keeps the deep-freeze contract honest —
222
+ # a later `.freeze` would otherwise silently no-op an already-frozen
223
+ # reference, but a caller probing `frozen?` would see the right answer.
194
224
  if value.is_a?(Proc)
195
225
  value.freeze
196
226
  else
@@ -218,6 +248,7 @@ module Ruact
218
248
  when :max_upload_bytes then validate_max_upload_bytes!(value)
219
249
  when :query_route_prefix then validate_query_route_prefix!(value)
220
250
  when :query_parent_controller then validate_query_parent_controller!(value)
251
+ when :shadcn_compatible_versions then validate_shadcn_compatible_versions!(value)
221
252
  end
222
253
  end
223
254
 
@@ -261,6 +292,27 @@ module Ruact
261
292
  "got #{value.inspect} (#{value.class.name})."
262
293
  end
263
294
 
295
+ # Story 10.5 — the scaffold generator validates the detected shadcn major
296
+ # against this list (`Array#include?`), so a non-Array would raise at the
297
+ # first scaffold run instead of at boot, and an empty Array would make every
298
+ # detected major "incompatible" (a global false warning). Both are
299
+ # configuration-time errors. Element-type strictness (Integer-only majors)
300
+ # is enforced too — a `["2"]` typo would silently never match a detected
301
+ # Integer major and warn on every run.
302
+ def validate_shadcn_compatible_versions!(value)
303
+ unless value.is_a?(Array) && !value.empty?
304
+ raise Ruact::ConfigurationError,
305
+ "Ruact::Configuration#shadcn_compatible_versions must be a non-empty Array " \
306
+ "of major-version Integers (e.g. [1, 2]); " \
307
+ "got #{value.inspect} (#{value.class.name})."
308
+ end
309
+ return if value.all?(Integer)
310
+
311
+ raise Ruact::ConfigurationError,
312
+ "Ruact::Configuration#shadcn_compatible_versions must contain only Integer " \
313
+ "major versions (e.g. [1, 2]); got #{value.inspect}."
314
+ end
315
+
264
316
  def build_error_message(attr, location)
265
317
  <<~MSG.strip
266
318
  ruact: cannot mutate Ruact::Configuration##{attr} after initialization.