promptly 0.1.2 → 0.1.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 23c7ae8e3735bb7596001fa52d79a218068be2f0c03ddbb1a5fb8aa263035489
4
- data.tar.gz: a44b67aa11f2ca04c26c6b291369d4f6c7c9044c5bd61ed8cfc921b01afd9df5
3
+ metadata.gz: acd140832db9c4f719f094548791c956ec3cb0f7380cf1c213e9923be1070fe2
4
+ data.tar.gz: abdba3ecdc1c1d4a03f012944c5ebc08d906c02266872eb9e3fb54483add5f55
5
5
  SHA512:
6
- metadata.gz: 382205e203447a670cb437b0a04a8ad42d137fe887d5690afe50bc3402f13be5b1ec0395e7d9450420aa60d7ae194283b3211dd1a417f620555733d2bb6d0859
7
- data.tar.gz: 90b523fa6fa36447e72e4dfda79c500510448bd9f4ed1a9353b30bae7b024d8a13b4d753cdb522f56bdab59dcea40d0be532b7fd8afdc51c5caf6abe66cdba4a
6
+ metadata.gz: b714d8ec43e3491248fc5a229cbbc2018227a28b32b68d84e8961fab5fe5185b860c55de425f3c573eb9b776f1922b5e56b331a6cf2be95cf7d8f33b303f8d1a
7
+ data.tar.gz: 69afcef9ff51ba00326a5e1efa1226777aecc97cfc9f9d85998a960353758a05b1fa967cef716dd441618f68afe67194333c0f4c1253a1b773d3f2818930dd12
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 3.3.4
data/README.md CHANGED
@@ -1,3 +1,4 @@
1
+
1
2
  # Promptly
2
3
 
3
4
  Opinionated Rails integration for reusable AI prompt templates. Build maintainable, localized, and testable AI prompts using ERB or Liquid templates with Rails conventions.
@@ -7,8 +8,9 @@ Opinionated Rails integration for reusable AI prompt templates. Build maintainab
7
8
  - **Template rendering**: ERB (via ActionView) and optional Liquid support
8
9
  - **I18n integration**: Automatic locale fallback (`welcome.es.erb` → `welcome.en.erb` → `welcome.erb`)
9
10
  - **Rails conventions**: Store prompts in `app/prompts/` with organized subdirectories
10
- - **Preview & CLI**: Test prompts in Rails console or via rake tasks
11
+ - **Render & CLI**: Test prompts in Rails console or via rake tasks
11
12
  - **Minimal setup**: Auto-loads via Railtie, zero configuration required
13
+ - **Prompt caching**: Configurable cache store, TTL, and cache-bypass options
12
14
 
13
15
  ## Install
14
16
 
@@ -82,7 +84,7 @@ Mantén el email conciso (menos de 200 palabras) y orientado a la acción.
82
84
 
83
85
  ```ruby
84
86
  # In a controller, service, or anywhere in Rails
85
- prompt = Promptly.preview(
87
+ prompt = Promptly.render(
86
88
  "user_onboarding/welcome_email",
87
89
  locale: :es,
88
90
  locals: {
@@ -109,8 +111,8 @@ puts ai_response.dig("choices", 0, "message", "content")
109
111
  ```ruby
110
112
  rails console
111
113
 
112
- # Preview the prompt before sending to AI
113
- prompt = Promptly.preview(
114
+ # Render the prompt before sending to AI
115
+ prompt = Promptly.render(
114
116
  "user_onboarding/welcome_email",
115
117
  locale: :en,
116
118
  locals: {
@@ -125,7 +127,7 @@ puts prompt
125
127
 
126
128
  # Uses I18n.locale by default
127
129
  I18n.locale = :es
128
- prompt = Promptly.preview(
130
+ prompt = Promptly.render(
129
131
  "user_onboarding/welcome_email",
130
132
  locals: {
131
133
  name: "María García",
@@ -137,16 +139,42 @@ prompt = Promptly.preview(
137
139
  )
138
140
  ```
139
141
 
140
- ### 4. CLI preview
142
+ ### 4. CLI rendering
141
143
 
142
144
  ```bash
143
- # Preview specific locale (shows the prompt, not AI output)
144
- rails ai_prompts:preview[user_onboarding/welcome_email,es]
145
+ # Render specific locale (shows the prompt, not AI output)
146
+ rails ai_prompts:render[user_onboarding/welcome_email,es]
145
147
 
146
148
  # Uses default locale
147
- rails ai_prompts:preview[user_onboarding/welcome_email]
149
+ rails ai_prompts:render[user_onboarding/welcome_email]
150
+ ```
151
+
152
+ ## Helper: render_prompt
153
+
154
+ Use a concise helper anywhere in Rails to render prompts with locals that are also exposed as instance variables inside ERB templates.
155
+
156
+ * **Auto-included**: Controllers, Mailers, and Jobs via Railtie.
157
+ * **Services/Plain Ruby**: `include Promptly::Helper`.
158
+
159
+ Example template and usage:
160
+
161
+ ```erb
162
+ # app/prompts/welcome_email.erb
163
+ Hello <%= @user.name %>, welcome to our service!
164
+ We're excited to have you join.
148
165
  ```
149
166
 
167
+ ```ruby
168
+ # In a mailer, job, controller, or a service that includes Promptly::Helper
169
+ rendered = render_prompt("welcome_email", user: @user)
170
+ ```
171
+
172
+ Notes:
173
+
174
+ - **Locals become @instance variables** in ERB. Passing `user: @user` makes `@user` available in the template.
175
+ - **Localization**: `render_prompt("welcome_email", locale: :es, user: user)` resolves `welcome_email.es.erb` with fallback per `Promptly::Locator`.
176
+ - **Caching**: Controlled per call (`cache:`, `ttl:`) and globally via `Promptly::Cache`.
177
+
150
178
  ## Rails App Integration
151
179
 
152
180
  ### Service Object Pattern
@@ -155,7 +183,7 @@ rails ai_prompts:preview[user_onboarding/welcome_email]
155
183
  # app/services/ai_prompt_service.rb
156
184
  class AiPromptService
157
185
  def self.generate_welcome_email(user, locale: I18n.locale)
158
- prompt = Promptly.preview(
186
+ prompt = Promptly.render(
159
187
  "user_onboarding/welcome_email",
160
188
  locale: locale,
161
189
  locals: {
@@ -219,7 +247,7 @@ class GenerateAiContentJob < ApplicationJob
219
247
  def perform(user_id, prompt_identifier, locals = {})
220
248
  user = User.find(user_id)
221
249
 
222
- prompt = Promptly.preview(
250
+ prompt = Promptly.render(
223
251
  prompt_identifier,
224
252
  locale: user.locale,
225
253
  locals: locals.merge(
@@ -302,7 +330,7 @@ I18n.locale = :es
302
330
  I18n.default_locale = :en
303
331
 
304
332
  # Will try: welcome_email.es.erb → welcome_email.en.erb → welcome_email.erb
305
- prompt = Promptly.preview(
333
+ prompt = Promptly.render(
306
334
  "user_onboarding/welcome_email",
307
335
  locals: {
308
336
  name: "María García",
@@ -314,7 +342,7 @@ prompt = Promptly.preview(
314
342
  )
315
343
 
316
344
  # Force specific locale for AI prompt generation
317
- prompt = Promptly.preview(
345
+ prompt = Promptly.render(
318
346
  "content_generation/blog_post_outline",
319
347
  locale: :fr,
320
348
  locals: {
@@ -356,7 +384,7 @@ Format your response as a conversational coaching session, not a formal report.
356
384
 
357
385
  ```ruby
358
386
  # Generate AI coaching content with Liquid template
359
- prompt = Promptly.preview(
387
+ prompt = Promptly.render(
360
388
  "ai_coaching/goal_review",
361
389
  locale: :en,
362
390
  locals: {
@@ -384,29 +412,146 @@ ai_coaching_session = openai_client.chat(
384
412
  Promptly.prompts_path = Rails.root.join("lib", "ai_prompts")
385
413
  ```
386
414
 
415
+ ### Caching
416
+
417
+ Promptly supports optional caching for rendered prompts.
418
+
419
+ - Default: enabled, TTL = 3600 seconds (1 hour).
420
+ - In Rails, the Railtie auto-uses `Rails.cache` if present.
421
+
422
+ Configure globally:
423
+
424
+ ```ruby
425
+ # config/initializers/promptly.rb
426
+ Promptly::Cache.configure do |c|
427
+ c.store = Rails.cache # or any ActiveSupport::Cache store
428
+ c.ttl = 3600 # default TTL in seconds
429
+ c.enabled = true # globally enable/disable caching
430
+ end
431
+ ```
432
+
433
+ Per-call options:
434
+
435
+ ```ruby
436
+ # Bypass cache for this render only
437
+ Promptly.render("user_onboarding/welcome_email", locals: {...}, cache: false)
438
+
439
+ # Custom TTL for this render only
440
+ Promptly.render("user_onboarding/welcome_email", locals: {...}, ttl: 5.minutes)
441
+ ```
442
+
443
+ Invalidation:
444
+
445
+ ```ruby
446
+ # Clear entire cache store (if supported by the store)
447
+ Promptly::Cache.clear
448
+
449
+ # Delete a specific cached entry
450
+ Promptly::Cache.delete(
451
+ identifier: "user_onboarding/welcome_email",
452
+ locale: :en,
453
+ locals: {name: "John"},
454
+ prompts_path: Promptly.prompts_path
455
+ )
456
+ ```
457
+
387
458
  ### Direct Template Rendering
388
459
 
389
460
  ```ruby
390
461
  # Render ERB directly (without file lookup)
391
462
  template = "Hello <%= name %>, welcome to <%= app %>!"
392
- output = Promptly.render(template, locals: {name: "John", app: "MyApp"})
463
+ output = Promptly.render_template(template, locals: {name: "John", app: "MyApp"})
393
464
 
394
465
  # Render Liquid directly
395
466
  template = "Hello {{ name }}, welcome to {{ app }}!"
396
- output = Promptly.render(template, locals: {name: "John", app: "MyApp"}, engine: :liquid)
467
+ output = Promptly.render_template(template, locals: {name: "John", app: "MyApp"}, engine: :liquid)
397
468
  ```
398
469
 
470
+ ## Generators
471
+
472
+ Create prompt templates following conventions.
473
+
474
+ ```bash
475
+ # ERB with multiple locales
476
+ rails g promptly:prompt user_onboarding/welcome_email --locales en es --engine erb
477
+
478
+ # Liquid with a single locale
479
+ rails g promptly:prompt ai_coaching/goal_review --locales en --engine liquid
480
+
481
+ # Fallback-only (no locale suffix)
482
+ rails g promptly:prompt content_generation/outline --no-locale
483
+ ```
484
+
485
+ Options:
486
+
487
+ - `--engine` erb|liquid (default: erb)
488
+ - `--locales` space-separated list (default: I18n.available_locales if available, else `en`)
489
+ - `--no-locale` create only fallback file (e.g., `welcome_email.erb`)
490
+ - `--force` overwrite existing files
491
+
492
+ Generated files are placed under `app/prompts/` and directories are created as needed.
493
+
494
+ Examples:
495
+
496
+ - `app/prompts/user_onboarding/welcome_email.en.erb`
497
+ - `app/prompts/user_onboarding/welcome_email.es.erb`
498
+ - `app/prompts/ai_coaching/goal_review.en.liquid`
499
+ - `app/prompts/content_generation/outline.erb` (fallback-only)
500
+
501
+ The generator seeds a minimal, intention-revealing scaffold you can edit immediately.
502
+
503
+ ## Linting Templates
504
+
505
+ Validate your prompt templates from the CLI.
506
+
507
+ ```bash
508
+ # Lint all templates under the prompts path
509
+ rake ai_prompts:lint
510
+
511
+ # Lint a specific identifier (path without locale/ext)
512
+ rake ai_prompts:lint[user_onboarding/welcome_email]
513
+
514
+ # Specify locales to check for coverage
515
+ LOCALES=en,es rake ai_prompts:lint
516
+
517
+ # Require placeholders to exist in templates
518
+ REQUIRED=name,app_name rake ai_prompts:lint[user_onboarding/welcome_email]
519
+
520
+ # Point to a custom prompts directory
521
+ PROMPTS_PATH=lib/ai_prompts rake ai_prompts:lint
522
+ ```
523
+
524
+ What it checks:
525
+
526
+ - **Syntax errors**
527
+ - ERB: compiles with `ERB.new` (no execution)
528
+ - Liquid: parses with `Liquid::Template.parse` (if `liquid` gem present)
529
+ - **Missing locale files**
530
+ - For each identifier, warns when required locales are missing
531
+ - Locales source: `LOCALES` env or `I18n.available_locales`
532
+ - **Required placeholders**
533
+ - Best-effort scan for required keys from `REQUIRED` env
534
+ - ERB: looks for `<%= ... @key ... %>` or `<%= ... key ... %>` usage
535
+ - Liquid: looks for `{{ key }}` usage
536
+
537
+ Exit codes:
538
+
539
+ - `0` when all checks pass
540
+ - `1` when errors are found (syntax or missing required placeholders)
541
+
399
542
  ## API Reference
400
543
 
401
- ### `Promptly.preview(identifier, locale: nil, locals: {})`
544
+ ### `Promptly.render(identifier, locale: nil, locals: {}, cache: true, ttl: nil)`
402
545
 
403
- Renders a template by identifier with locale fallback.
546
+ Renders a template by identifier with locale fallback and optional caching.
404
547
 
405
548
  - **identifier**: Template path like `"user_onboarding/welcome"`
406
549
  - **locale**: Specific locale (defaults to `I18n.locale`)
407
550
  - **locals**: Hash of variables for template
551
+ - **cache**: Enable/disable caching for this call (defaults to `true`)
552
+ - **ttl**: Time-to-live in seconds for cache entry (overrides default TTL)
408
553
 
409
- ### `Promptly.render(template, locals: {}, engine: :erb)`
554
+ ### `Promptly.render_template(template, locals: {}, engine: :erb)`
410
555
 
411
556
  Renders template string directly.
412
557
 
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Rails generator: promptly:prompt
4
+ # Usage:
5
+ # rails g promptly:prompt user_onboarding/welcome_email --locales en es --engine erb
6
+ # Creates prompt templates under app/prompts/ following conventions.
7
+
8
+ require "fileutils"
9
+
10
+ begin
11
+ require "rails/generators"
12
+ require "rails/generators/named_base"
13
+ rescue LoadError
14
+ # Allow gem to load without Rails present
15
+ end
16
+
17
+ module Promptly
18
+ module Generators
19
+ class PromptGenerator < (defined?(Rails::Generators::NamedBase) ? Rails::Generators::NamedBase : Object)
20
+ if defined?(Rails::Generators::NamedBase)
21
+ argument :name, type: :string, required: true, desc: "Prompt identifier, e.g., user_onboarding/welcome_email"
22
+
23
+ class_option :engine, type: :string, default: "erb", desc: "Template engine: erb or liquid"
24
+ class_option :locales, type: :array, default: [], desc: "Locales to generate (e.g., en es fr)"
25
+ class_option :no_locale, type: :boolean, default: false, desc: "Generate only fallback (no locale suffix)"
26
+ class_option :force, type: :boolean, default: false, desc: "Overwrite existing files"
27
+
28
+ def create_prompt_files
29
+ id_path = name.to_s
30
+ base_dir = File.join("app", "prompts", File.dirname(id_path))
31
+ basename = File.basename(id_path)
32
+
33
+ FileUtils.mkdir_p(base_dir)
34
+
35
+ ext = engine_extension
36
+ resolve_targets(basename, ext).each do |rel|
37
+ path = File.join(base_dir, rel)
38
+ if File.exist?(path) && !options[:force]
39
+ say_status :skip, path
40
+ next
41
+ end
42
+ create_file(path, content_for_engine(ext), force: true)
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ def engine_extension
49
+ engine = options[:engine].to_s.downcase
50
+ case engine
51
+ when "erb" then "erb"
52
+ when "liquid" then "liquid"
53
+ else
54
+ say_status :error, "Unknown engine '#{engine}'. Use erb or liquid.", :red
55
+ exit(1)
56
+ end
57
+ end
58
+
59
+ def resolved_locales
60
+ return [] if options[:no_locale]
61
+
62
+ if options[:locales].any?
63
+ options[:locales].map(&:to_s)
64
+ elsif defined?(I18n) && I18n.respond_to?(:available_locales)
65
+ Array(I18n.available_locales).map(&:to_s)
66
+ else
67
+ ["en"]
68
+ end
69
+ end
70
+
71
+ def resolve_targets(basename, ext)
72
+ locs = resolved_locales
73
+ if options[:no_locale] || locs.empty?
74
+ ["#{basename}.#{ext}"]
75
+ else
76
+ locs.uniq.map { |loc| "#{basename}.#{loc}.#{ext}" }
77
+ end
78
+ end
79
+
80
+ def content_for_engine(ext)
81
+ case ext
82
+ when "erb"
83
+ <<~ERB
84
+ <!-- Prompt: generated by promptly:prompt -->
85
+ <!-- Identifier: #{name} -->
86
+
87
+ You are an AI assistant. Fill in content below using provided locals.
88
+
89
+ Context:
90
+ - Example local: <%= example || "value" %>
91
+
92
+ Task:
93
+ 1. Explain the goal.
94
+ 2. Provide actionable steps.
95
+ 3. Keep it concise and clear.
96
+ ERB
97
+ when "liquid"
98
+ <<~LIQ
99
+ {# Prompt: generated by promptly:prompt #}
100
+ {# Identifier: #{name} #}
101
+
102
+ You are an AI assistant. Fill in content below using provided locals.
103
+
104
+ Context:
105
+ - Example local: {{ example | default: "value" }}
106
+
107
+ Task:
108
+ 1. Explain the goal.
109
+ 2. Provide actionable steps.
110
+ 3. Keep it concise and clear.
111
+ LIQ
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Promptly
6
+ class Cache
7
+ class << self
8
+ attr_accessor :store, :enabled, :ttl
9
+
10
+ def configure
11
+ yield self
12
+ end
13
+
14
+ def enabled?
15
+ @enabled != false && store
16
+ end
17
+
18
+ def fetch(key, ttl: nil, &block)
19
+ return yield unless enabled?
20
+
21
+ cache_key = generate_key(key)
22
+ cached_value = store.read(cache_key)
23
+
24
+ if cached_value
25
+ cached_value
26
+ else
27
+ value = yield
28
+ store.write(cache_key, value, expires_in: ttl || self.ttl)
29
+ value
30
+ end
31
+ end
32
+
33
+ def clear
34
+ return unless enabled? && store.respond_to?(:clear)
35
+
36
+ store.clear
37
+ end
38
+
39
+ def delete(key)
40
+ return unless enabled?
41
+
42
+ cache_key = generate_key(key)
43
+ store.delete(cache_key)
44
+ end
45
+
46
+ private
47
+
48
+ def generate_key(key_data)
49
+ case key_data
50
+ when String
51
+ "promptly:#{key_data}"
52
+ when Hash
53
+ content = key_data.sort.to_s
54
+ hash = Digest::SHA256.hexdigest(content)
55
+ "promptly:#{hash}"
56
+ else
57
+ "promptly:#{Digest::SHA256.hexdigest(key_data.to_s)}"
58
+ end
59
+ end
60
+ end
61
+
62
+ # Default configuration
63
+ self.enabled = true
64
+ self.ttl = 3600 # 1 hour default TTL
65
+ self.store = nil
66
+ end
67
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Promptly
4
+ module Helper
5
+ # Render a prompt template by identifier.
6
+ #
7
+ # Example:
8
+ # # app/prompts/welcome_email.erb
9
+ # # Hello <%= @user.name %>, welcome!
10
+ #
11
+ # # In a mailer, job, or service including this module:
12
+ # # render_prompt("welcome_email", user: @user)
13
+ #
14
+ # Supports locale-aware lookup and caching.
15
+ def render_prompt(identifier, locale: nil, cache: true, ttl: nil, **locals)
16
+ Promptly.render(identifier, locale: locale, locals: locals, cache: cache, ttl: ttl)
17
+ end
18
+ end
19
+ end
@@ -6,6 +6,26 @@ module Promptly
6
6
  # Intentionally minimal per DHH style; conventions over configuration
7
7
  # Hook points will be added as features land.
8
8
  Rails.logger.info("[promptly] loaded") if defined?(Rails.logger)
9
+
10
+ # Auto-configure Rails cache if available
11
+ if defined?(Rails.cache)
12
+ Promptly::Cache.store = Rails.cache
13
+ end
14
+
15
+ # Make render_prompt available in mailers, jobs, and controllers
16
+ if defined?(ActiveSupport)
17
+ ActiveSupport.on_load(:action_mailer) do
18
+ include Promptly::Helper
19
+ end
20
+
21
+ ActiveSupport.on_load(:active_job) do
22
+ include Promptly::Helper
23
+ end
24
+
25
+ ActiveSupport.on_load(:action_controller) do
26
+ include Promptly::Helper
27
+ end
28
+ end
9
29
  end
10
30
 
11
31
  rake_tasks do
@@ -26,6 +26,12 @@ module Promptly
26
26
  lookup = ActionView::LookupContext.new(ActionView::PathSet.new([]))
27
27
  av = view_class.new(lookup, {}, nil)
28
28
 
29
+ # Make locals available both as locals and instance variables (e.g., @user)
30
+ (locals || {}).each do |k, v|
31
+ ivar = "@#{k}"
32
+ av.instance_variable_set(ivar, v)
33
+ end
34
+
29
35
  av.render(inline: template, type: :erb, locals: locals || {})
30
36
  end
31
37
 
@@ -1,25 +1,143 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  namespace :ai_prompts do
4
- desc "Preview a prompt: rake ai_prompts:preview[identifier,locale]"
5
- task :preview, [:identifier, :locale] => :environment do |_, args|
4
+ desc "Render a prompt: rake ai_prompts:render[identifier,locale]"
5
+ task :render, [:identifier, :locale] => :environment do |_, args|
6
6
  identifier = args[:identifier]
7
7
  locale = args[:locale]
8
8
  prompts_path = ENV["PROMPTS_PATH"]
9
9
 
10
10
  unless identifier
11
- warn "Usage: rake ai_prompts:preview[identifier,locale]"
11
+ warn "Usage: rake ai_prompts:render[identifier,locale]"
12
12
  exit 1
13
13
  end
14
14
 
15
15
  begin
16
16
  Promptly.prompts_path = prompts_path if prompts_path
17
17
 
18
- output = Promptly.preview(identifier, locale: locale)
18
+ output = Promptly.render(identifier, locale: locale)
19
19
  puts output
20
20
  rescue Promptly::Error => e
21
21
  warn "Error: #{e.class}: #{e.message}"
22
22
  exit 1
23
23
  end
24
24
  end
25
+
26
+ desc "Lint prompt templates. Usage: rake ai_prompts:lint[identifier] LOCALES=en,es REQUIRED=name,app_name PROMPTS_PATH=..."
27
+ task :lint, [:identifier] => :environment do |_, args|
28
+ require "erb"
29
+
30
+ prompts_path = ENV["PROMPTS_PATH"] || Promptly.prompts_path
31
+ identifier_filter = args[:identifier]
32
+
33
+ locales = if ENV["LOCALES"]
34
+ ENV["LOCALES"].split(",").map(&:strip).reject(&:empty?)
35
+ elsif defined?(I18n) && I18n.respond_to?(:available_locales)
36
+ I18n.available_locales.map(&:to_s)
37
+ else
38
+ []
39
+ end
40
+
41
+ required_keys = (ENV["REQUIRED"] || "").split(",").map(&:strip).reject(&:empty?)
42
+
43
+ unless File.directory?(prompts_path)
44
+ warn "[lint] prompts_path not found: #{prompts_path}"
45
+ exit 1
46
+ end
47
+
48
+ exts = Promptly::Locator::SUPPORTED_EXTS
49
+
50
+ files = Dir.glob(File.join(prompts_path, "**", "*{#{exts.join(",")}}"))
51
+ if identifier_filter
52
+ files.select! do |f|
53
+ # match by identifier path without locale/ext
54
+ rel = f.sub(/^#{Regexp.escape(prompts_path)}\//, "")
55
+ base = rel.sub(/\.(?:[a-z]{2})?(?:#{exts.map { |e| Regexp.escape(e) }.join("|")})\z/, "")
56
+ base == identifier_filter
57
+ end
58
+ end
59
+
60
+ if files.empty?
61
+ warn "[lint] No templates found under #{prompts_path}#{identifier_filter ? " for '#{identifier_filter}'" : ""}"
62
+ exit 1
63
+ end
64
+
65
+ status = 0
66
+
67
+ # Group by identifier (path without locale/ext)
68
+ grouped = files.group_by do |f|
69
+ rel = f.sub(/^#{Regexp.escape(prompts_path)}\//, "")
70
+ rel.sub(/\.(?:[a-z]{2})?(?:#{exts.map { |e| Regexp.escape(e) }.join("|")})\z/, "")
71
+ end
72
+
73
+ grouped.each do |identifier, paths|
74
+ puts "[lint] Identifier: #{identifier}"
75
+
76
+ # 1) Syntax check and placeholder scan per file
77
+ paths.each do |path|
78
+ engine = Promptly::Locator.engine_for(path)
79
+ content = File.read(path)
80
+
81
+ begin
82
+ case engine
83
+ when :erb
84
+ # Compile ERB to Ruby, don't execute
85
+ ERB.new(content)
86
+ when :liquid
87
+ if defined?(::Liquid)
88
+ ::Liquid::Template.parse(content)
89
+ else
90
+ warn " - WARN: Liquid not available; skipping syntax parse for #{File.basename(path)}"
91
+ end
92
+ end
93
+ rescue => e
94
+ warn " - ERROR: Syntax error in #{File.basename(path)}: #{e.class}: #{e.message}"
95
+ status = 1
96
+ end
97
+
98
+ # Required placeholder presence (best-effort scan)
99
+ if required_keys.any?
100
+ missing = []
101
+ required_keys.each do |key|
102
+ present = false
103
+ case engine
104
+ when :erb
105
+ # naive checks: @key or key inside ERB output tags
106
+ present ||= content.match?(/<%[=\-].*?@#{Regexp.escape(key)}[\W]/m)
107
+ present ||= content.match?(/<%[=\-].*?\b#{Regexp.escape(key)}\b/m)
108
+ when :liquid
109
+ present ||= content.match?(/\{\{\s*#{Regexp.escape(key)}[\s\|\}]/)
110
+ end
111
+ missing << key unless present
112
+ end
113
+ if missing.any?
114
+ warn " - ERROR: Missing required placeholders in #{File.basename(path)}: #{missing.join(", ")}"
115
+ status = 1
116
+ end
117
+ end
118
+ end
119
+
120
+ # 2) Missing locale files (if locales provided)
121
+ if locales.any?
122
+ found_locales = paths.map do |p|
123
+ # extract locale between name and extension: name.<locale>.ext
124
+ File.basename(p)[/\.([a-z]{2})\.(?:erb|liquid)\z/, 1]
125
+ end.compact.uniq
126
+
127
+ missing_locales = locales - found_locales
128
+ if missing_locales.any?
129
+ warn " - WARN: Missing locale templates for #{identifier}: #{missing_locales.join(", ")}"
130
+ else
131
+ puts " - OK: Locale coverage satisfied"
132
+ end
133
+ end
134
+ end
135
+
136
+ if status.zero?
137
+ puts "[lint] OK"
138
+ else
139
+ warn "[lint] FAIL"
140
+ end
141
+ exit status
142
+ end
25
143
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Promptly
4
- VERSION = "0.1.2"
4
+ VERSION = "0.1.13"
5
5
  end
data/lib/promptly.rb CHANGED
@@ -3,11 +3,13 @@
3
3
  require_relative "promptly/version"
4
4
  require_relative "promptly/renderer"
5
5
  require_relative "promptly/locator"
6
+ require_relative "promptly/cache"
7
+ require_relative "promptly/helper"
6
8
 
7
9
  module Promptly
8
10
  class Error < StandardError; end
9
11
 
10
- def self.render(template, locals: {}, engine: :erb)
12
+ def self.render_template(template, locals: {}, engine: :erb)
11
13
  Renderer.render(template, locals: locals, engine: engine)
12
14
  end
13
15
 
@@ -20,10 +22,27 @@ module Promptly
20
22
  @prompts_path = path
21
23
  end
22
24
 
23
- # Preview a template by identifier using locator rules
25
+ # Render a template by identifier using locator rules
24
26
  # identifier: "user_onboarding/welcome"
25
27
  # locale: defaults to I18n.locale when available
26
- def self.preview(identifier, locale: nil, locals: {})
28
+ def self.render(identifier, locale: nil, locals: {}, cache: true, ttl: nil)
29
+ if cache && Cache.enabled?
30
+ cache_key = {
31
+ identifier: identifier,
32
+ locale: locale,
33
+ locals: locals,
34
+ prompts_path: prompts_path
35
+ }
36
+
37
+ Cache.fetch(cache_key, ttl: ttl) do
38
+ render_without_cache(identifier, locale: locale, locals: locals)
39
+ end
40
+ else
41
+ render_without_cache(identifier, locale: locale, locals: locals)
42
+ end
43
+ end
44
+
45
+ private_class_method def self.render_without_cache(identifier, locale: nil, locals: {})
27
46
  path = Locator.resolve(identifier, locale: locale)
28
47
  raise Error, "Template not found for '#{identifier}' (locale: #{locale.inspect}) under #{prompts_path}" unless path
29
48
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: promptly
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.1.13
5
5
  platform: ruby
6
6
  authors:
7
7
  - Wilbur Suero
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-08-16 00:00:00.000000000 Z
11
+ date: 2025-08-18 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: actionview
@@ -16,14 +16,14 @@ dependencies:
16
16
  requirements:
17
17
  - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: '7.0'
19
+ version: '7.2'
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
- version: '7.0'
26
+ version: '7.2'
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: rspec
29
29
  requirement: !ruby/object:Gem::Requirement
@@ -66,6 +66,20 @@ dependencies:
66
66
  - - "~>"
67
67
  - !ruby/object:Gem::Version
68
68
  version: '5.5'
69
+ - !ruby/object:Gem::Dependency
70
+ name: railties
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '7.2'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '7.2'
69
83
  description: Build maintainable, localized, and testable AI prompts using ERB or Liquid
70
84
  templates with Rails conventions
71
85
  email:
@@ -75,17 +89,20 @@ extensions: []
75
89
  extra_rdoc_files: []
76
90
  files:
77
91
  - ".rspec"
92
+ - ".ruby-version"
78
93
  - ".standard.yml"
79
94
  - LICENSE
80
95
  - README.md
81
96
  - Rakefile
97
+ - lib/generators/promptly/prompt_generator.rb
82
98
  - lib/promptly.rb
99
+ - lib/promptly/cache.rb
100
+ - lib/promptly/helper.rb
83
101
  - lib/promptly/locator.rb
84
102
  - lib/promptly/railtie.rb
85
103
  - lib/promptly/renderer.rb
86
104
  - lib/promptly/tasks/ai_prompts.rake
87
105
  - lib/promptly/version.rb
88
- - promptly.gemspec
89
106
  homepage: https://github.com/wilburhimself/promptly
90
107
  licenses:
91
108
  - MIT
@@ -103,7 +120,10 @@ required_ruby_version: !ruby/object:Gem::Requirement
103
120
  requirements:
104
121
  - - ">="
105
122
  - !ruby/object:Gem::Version
106
- version: 3.0.0
123
+ version: '3.2'
124
+ - - "<"
125
+ - !ruby/object:Gem::Version
126
+ version: '3.4'
107
127
  required_rubygems_version: !ruby/object:Gem::Requirement
108
128
  requirements:
109
129
  - - ">="
data/promptly.gemspec DELETED
@@ -1,44 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "lib/promptly/version"
4
-
5
- Gem::Specification.new do |spec|
6
- spec.name = "promptly"
7
- spec.version = Promptly::VERSION
8
- spec.authors = ["Wilbur Suero"]
9
- spec.email = ["wilbur@example.com"]
10
-
11
- spec.summary = "Opinionated Rails integration for reusable AI prompt templates"
12
- spec.description = "Build maintainable, localized, and testable AI prompts using ERB or Liquid templates with Rails conventions"
13
- spec.homepage = "https://github.com/wilburhimself/promptly"
14
- spec.license = "MIT"
15
- spec.required_ruby_version = ">= 3.0.0"
16
-
17
- spec.metadata["allowed_push_host"] = "https://rubygems.org"
18
-
19
- spec.metadata["homepage_uri"] = spec.homepage
20
- spec.metadata["source_code_uri"] = "https://github.com/wilburhimself/promptly"
21
- spec.metadata["changelog_uri"] = "https://github.com/wilburhimself/promptly/blob/main/CHANGELOG.md"
22
- spec.metadata["documentation_uri"] = "https://github.com/wilburhimself/promptly/blob/main/README.md"
23
-
24
- # Specify which files should be added to the gem when it is released.
25
- # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
26
- gemfiles = Dir.chdir(__dir__) do
27
- `git ls-files -z`.split("\x0").reject do |f|
28
- (File.expand_path(f) == __FILE__) || f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile])
29
- end
30
- end
31
- spec.files = gemfiles
32
-
33
- spec.bindir = "exe"
34
- spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
35
- spec.require_paths = ["lib"]
36
-
37
- # Runtime dependencies
38
- spec.add_dependency "actionview", "~> 7.0"
39
-
40
- # Development dependencies
41
- spec.add_development_dependency "rspec", "~> 3.12"
42
- spec.add_development_dependency "standard", "~> 1.37"
43
- spec.add_development_dependency "liquid", "~> 5.5"
44
- end