tina4ruby 3.12.10 → 3.12.13

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.
data/lib/tina4/router.rb CHANGED
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "fileutils"
4
+ require "json"
5
+
3
6
  module Tina4
4
7
  class Route
5
8
  attr_reader :method, :path, :handler, :auth_handler, :swagger_meta,
@@ -312,6 +315,25 @@ module Tina4
312
315
  add("ANY", path, block, middleware: middleware, swagger_meta: swagger_meta, template: template)
313
316
  end
314
317
 
318
+ # Register an explicit HEAD route. By default the framework auto-handles
319
+ # HEAD by falling back to the GET route and stripping the body
320
+ # (RFC 9110 §9.3.2). Use this only when you need a HEAD handler that
321
+ # does something different from GET — e.g. cheaper existence-check
322
+ # logic, custom validator headers without the cost of building the body.
323
+ # The framework still strips the response body for you on the way out.
324
+ def head(path, middleware: [], swagger_meta: {}, template: nil, &block)
325
+ add("HEAD", path, block, middleware: middleware, swagger_meta: swagger_meta, template: template)
326
+ end
327
+
328
+ # Register an explicit OPTIONS route. By default the framework auto-
329
+ # handles OPTIONS by building an Allow header from every method
330
+ # registered for the path and returning 204 (RFC 9110 §9.3.7). Use
331
+ # this to take over that behaviour — e.g. to return a richer OPTIONS
332
+ # payload describing the resource.
333
+ def options(path, middleware: [], swagger_meta: {}, template: nil, &block)
334
+ add("OPTIONS", path, block, middleware: middleware, swagger_meta: swagger_meta, template: template)
335
+ end
336
+
315
337
  def find_route(method, path)
316
338
  normalized_method = method.upcase
317
339
  # Normalize path once (not per-route)
@@ -325,9 +347,59 @@ module Tina4
325
347
  params = route.match_path(normalized_path)
326
348
  return [route, params] if params
327
349
  end
350
+
351
+ # RFC 9110 §9.3.2: HEAD is identical to GET except for the absence
352
+ # of a response body. If no explicit HEAD route matched, fall back
353
+ # to the GET route — the dispatcher strips the body on the way out
354
+ # so the handler doesn't need to know HEAD even happened.
355
+ if normalized_method == "HEAD"
356
+ (method_index["GET"] || []).each do |route|
357
+ params = route.match_path(normalized_path)
358
+ return [route, params] if params
359
+ end
360
+ end
361
+
328
362
  nil
329
363
  end
330
364
 
365
+ # Return the list of HTTP methods registered for ``path``, in the order
366
+ # GET / POST / PUT / PATCH / DELETE / HEAD / OPTIONS. Used by the
367
+ # dispatcher to build the ``Allow:`` header on 405 / OPTIONS responses
368
+ # (RFC 9110 §10.2.1, §9.3.7).
369
+ #
370
+ # If GET is registered for the path, HEAD is appended implicitly
371
+ # (HEAD auto-fallback). OPTIONS is appended whenever the path has any
372
+ # registered method (the framework auto-handles OPTIONS).
373
+ def methods_allowed_for_path(path)
374
+ normalized_path = path.gsub("\\", "/")
375
+ normalized_path = "/#{normalized_path}" unless normalized_path.start_with?("/")
376
+ normalized_path = normalized_path.chomp("/") unless normalized_path == "/"
377
+
378
+ method_order = %w[GET POST PUT PATCH DELETE HEAD OPTIONS]
379
+ seen = []
380
+ any_matched = false
381
+
382
+ method_index.each do |m, routes_for_method|
383
+ next if routes_for_method.empty?
384
+ matched = routes_for_method.any? { |r| r.match_path(normalized_path) }
385
+ next unless matched
386
+ if m == "ANY"
387
+ any_matched = true
388
+ elsif method_order.include?(m)
389
+ seen << m unless seen.include?(m)
390
+ end
391
+ end
392
+
393
+ seen = method_order.dup if any_matched
394
+
395
+ if !seen.empty?
396
+ seen << "HEAD" if seen.include?("GET") && !seen.include?("HEAD")
397
+ seen << "OPTIONS" unless seen.include?("OPTIONS")
398
+ end
399
+
400
+ method_order.select { |m| seen.include?(m) }
401
+ end
402
+
331
403
  # When TINA4_TRAILING_SLASH_REDIRECT is truthy, the rack app uses this
332
404
  # to detect whether the *original* (un-stripped) path differed from the
333
405
  # canonical form so it can issue a 301 redirect. Default false — silent
@@ -369,17 +441,79 @@ module Tina4
369
441
  GroupContext.new(prefix, auth_handler, middleware).instance_eval(&block)
370
442
  end
371
443
 
372
- # Load route files from a directory (file-based route discovery)
444
+ # Load route files from a directory (file-based route discovery).
445
+ #
446
+ # Idempotent: files already loaded by a previous call are skipped, so
447
+ # calling load_routes repeatedly (e.g. on /__dev/api/reload) only
448
+ # picks up NEW files. Records the directory so #rescan_routes! can
449
+ # re-run without re-passing it.
373
450
  def load_routes(directory)
374
451
  return unless Dir.exist?(directory)
375
- Dir.glob(File.join(directory, "**/*.rb")).sort.each do |file|
452
+
453
+ @loaded_route_files ||= {}
454
+ @last_routes_dir = directory
455
+
456
+ files = Dir.glob(File.join(directory, "**/*.rb")).sort
457
+ total = files.length
458
+ files.each do |file|
459
+ next if @loaded_route_files[file]
376
460
  begin
377
461
  load file
462
+ @loaded_route_files[file] = true
378
463
  Tina4::Log.debug("Route loaded: #{file}")
379
- rescue => e
464
+ rescue ScriptError, StandardError => e
465
+ # ScriptError catches SyntaxError, which is NOT a StandardError —
466
+ # a bare `rescue => e` would let a syntax-broken route file crash
467
+ # the whole discovery pass.
380
468
  Tina4::Log.error("Failed to load route #{file}: #{e.message}")
469
+ record_broken_route_import(file, e)
381
470
  end
382
471
  end
472
+
473
+ # Zero-routes warning — src/routes/ has .rb files but the router
474
+ # is still empty. Almost certainly the user forgot Tina4::Router.get.
475
+ if total > 0 && routes.empty?
476
+ Tina4::Log.warning(
477
+ "Auto-discover found #{total} .rb file(s) in #{directory} but no routes registered. " \
478
+ "Each route file must call Tina4::Router.get / .post / etc."
479
+ )
480
+ end
481
+ end
482
+
483
+ # Re-run the most recent load_routes — called by /__dev/api/reload so
484
+ # files dropped into src/routes/ after server boot get picked up
485
+ # without a restart. No-op if load_routes has never been called.
486
+ def rescan_routes!
487
+ return [] if @last_routes_dir.nil? || @last_routes_dir.empty?
488
+ before = routes.length
489
+ load_routes(@last_routes_dir)
490
+ added = routes.length - before
491
+ Tina4::Log.info("Re-discovered #{added} new route(s) on reload") if added.positive?
492
+ added
493
+ end
494
+
495
+ # Test-only helper — reset the loaded-files state so tests can scan
496
+ # the same directory multiple times with different file contents.
497
+ def reset_route_discovery!
498
+ @loaded_route_files = {}
499
+ @last_routes_dir = nil
500
+ end
501
+
502
+ # Write a .broken sentinel so /health and the dev dashboard surface
503
+ # auto-discover failures instead of swallowing them into a log line.
504
+ def record_broken_route_import(file, error)
505
+ broken_dir = File.join(Dir.pwd, "data", ".broken")
506
+ FileUtils.mkdir_p(broken_dir) unless Dir.exist?(broken_dir)
507
+ slug = file.gsub(%r{[/\\]}, "_")
508
+ payload = JSON.generate(
509
+ type: "auto_discover_failure",
510
+ file: file,
511
+ error: "#{error.class}: #{error.message}"
512
+ )
513
+ File.write(File.join(broken_dir, "discover_#{slug}.broken"), payload)
514
+ rescue StandardError
515
+ # If the .broken write itself fails, the original error is already
516
+ # in the log — nothing more to do.
383
517
  end
384
518
  end
385
519
 
data/lib/tina4/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tina4
4
- VERSION = "3.12.10"
4
+ VERSION = "3.12.13"
5
5
  end
data/lib/tina4.rb CHANGED
@@ -44,6 +44,7 @@ require_relative "tina4/events"
44
44
  require_relative "tina4/plan"
45
45
  require_relative "tina4/project_index"
46
46
  require_relative "tina4/dev_admin"
47
+ require_relative "tina4/feedback"
47
48
  require_relative "tina4/messenger"
48
49
  require_relative "tina4/dev_mailbox"
49
50
  require_relative "tina4/ai"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tina4ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.12.10
4
+ version: 3.12.13
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tina4 Team
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-05-14 00:00:00.000000000 Z
11
+ date: 2026-05-29 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rack
@@ -306,6 +306,7 @@ files:
306
306
  - lib/tina4/env.rb
307
307
  - lib/tina4/error_overlay.rb
308
308
  - lib/tina4/events.rb
309
+ - lib/tina4/feedback.rb
309
310
  - lib/tina4/field_types.rb
310
311
  - lib/tina4/frond.rb
311
312
  - lib/tina4/gallery/auth/meta.json
@@ -337,6 +338,7 @@ files:
337
338
  - lib/tina4/orm.rb
338
339
  - lib/tina4/plan.rb
339
340
  - lib/tina4/project_index.rb
341
+ - lib/tina4/public/__feedback/widget.js
340
342
  - lib/tina4/public/css/tina4.css
341
343
  - lib/tina4/public/css/tina4.min.css
342
344
  - lib/tina4/public/favicon.ico