prompt_manager 1.0.2 → 1.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/CHANGELOG.md +138 -27
- data/README.md +127 -271
- data/lib/pm/configuration.rb +34 -4
- data/lib/pm/core_directives.rb +21 -20
- data/lib/pm/directive.rb +26 -34
- data/lib/pm/directives.rb +55 -16
- data/lib/pm/errors.rb +71 -0
- data/lib/pm/metadata.rb +110 -9
- data/lib/pm/parsed.rb +51 -46
- data/lib/pm/render_scope.rb +74 -0
- data/lib/pm/renderer.rb +59 -0
- data/lib/pm/shell.rb +95 -40
- data/lib/pm/version.rb +1 -1
- data/lib/pm.rb +116 -57
- data/lib/prompt_manager.rb +6 -0
- metadata +13 -113
- data/.envrc +0 -3
- data/.irbrc +0 -14
- data/COMMITS.md +0 -196
- data/Rakefile +0 -12
- data/docs/api/configuration.md +0 -59
- data/docs/api/constants.md +0 -60
- data/docs/api/index.md +0 -14
- data/docs/api/metadata.md +0 -99
- data/docs/api/parsed.md +0 -98
- data/docs/api/pm-module.md +0 -169
- data/docs/api/render-context.md +0 -51
- data/docs/architecture/design-decisions.md +0 -70
- data/docs/architecture/index.md +0 -6
- data/docs/architecture/processing-pipeline.md +0 -112
- data/docs/assets/css/custom.css +0 -1
- data/docs/assets/images/prompt_manager.gif +0 -0
- data/docs/assets/images/prompt_manager.mp4 +0 -0
- data/docs/examples/ai-agent-prompts.md +0 -173
- data/docs/examples/code-review-prompt.md +0 -107
- data/docs/examples/index.md +0 -7
- data/docs/examples/multi-file-composition.md +0 -123
- data/docs/getting-started/configuration.md +0 -106
- data/docs/getting-started/index.md +0 -7
- data/docs/getting-started/installation.md +0 -34
- data/docs/getting-started/quick-start.md +0 -94
- data/docs/guides/comment-stripping.md +0 -64
- data/docs/guides/custom-directives.md +0 -180
- data/docs/guides/erb-rendering.md +0 -102
- data/docs/guides/includes.md +0 -184
- data/docs/guides/index.md +0 -11
- data/docs/guides/parameters.md +0 -96
- data/docs/guides/parsing.md +0 -151
- data/docs/guides/shell-expansion.md +0 -108
- data/docs/index.md +0 -70
- data/mkdocs.yml +0 -181
data/lib/pm/directive.rb
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# lib/pm/directive.rb
|
|
4
|
-
#
|
|
5
3
|
# Base class for all directive categories.
|
|
6
4
|
#
|
|
7
5
|
# Subclass to define a category of directives. Use `desc` immediately
|
|
@@ -22,26 +20,21 @@
|
|
|
22
20
|
# alias_method :hi, :greet
|
|
23
21
|
# end
|
|
24
22
|
#
|
|
23
|
+
# PM::Directive.register_all
|
|
25
24
|
|
|
26
25
|
module PM
|
|
27
26
|
class Directive
|
|
28
27
|
# Centralized list of all subclasses across the hierarchy.
|
|
29
|
-
# Initialized here on PM::Directive; the inherited hook always
|
|
30
|
-
# appends to this specific array.
|
|
31
28
|
@directive_subclasses = []
|
|
32
29
|
|
|
33
30
|
class << self
|
|
34
31
|
# ---- Subclass tracking ------------------------------------------------
|
|
35
|
-
# Every subclass (direct or indirect) is tracked in PM::Directive's
|
|
36
|
-
# centralized list so register_all can find them all.
|
|
37
32
|
|
|
38
33
|
def inherited(subclass)
|
|
39
34
|
PM::Directive.directive_subclasses << subclass
|
|
40
35
|
super
|
|
41
36
|
end
|
|
42
37
|
|
|
43
|
-
# Returns the centralized subclass list. Only meaningful when called
|
|
44
|
-
# on PM::Directive itself; subclasses delegate here explicitly.
|
|
45
38
|
def directive_subclasses
|
|
46
39
|
if equal?(PM::Directive)
|
|
47
40
|
@directive_subclasses
|
|
@@ -64,23 +57,12 @@ module PM
|
|
|
64
57
|
directive_descriptions[method_name] = @_pending_desc
|
|
65
58
|
@_pending_desc = nil
|
|
66
59
|
else
|
|
67
|
-
|
|
68
|
-
# UnboundMethod#original_name returns the original method name;
|
|
69
|
-
# when it differs from method_name, this method is an alias.
|
|
70
|
-
begin
|
|
71
|
-
um = instance_method(method_name)
|
|
72
|
-
original = um.original_name
|
|
73
|
-
if original != method_name && directive_descriptions.key?(original)
|
|
74
|
-
(directive_aliases[original] ||= []) << method_name
|
|
75
|
-
end
|
|
76
|
-
rescue NameError
|
|
77
|
-
# ignore — method may reference undefined constants at load time
|
|
78
|
-
end
|
|
60
|
+
record_alias(method_name)
|
|
79
61
|
end
|
|
80
62
|
super
|
|
81
63
|
end
|
|
82
64
|
|
|
83
|
-
# Per-subclass metadata stores
|
|
65
|
+
# Per-subclass metadata stores.
|
|
84
66
|
|
|
85
67
|
def directive_descriptions
|
|
86
68
|
@directive_descriptions ||= {}
|
|
@@ -92,7 +74,7 @@ module PM
|
|
|
92
74
|
|
|
93
75
|
# ---- Category name derived from class name ----------------------------
|
|
94
76
|
# PM::CoreDirectives --> "Core"
|
|
95
|
-
# AIA::WebAndFileDirectives --> "Web
|
|
77
|
+
# AIA::WebAndFileDirectives --> "Web And File"
|
|
96
78
|
|
|
97
79
|
def category_name
|
|
98
80
|
name.split('::').last
|
|
@@ -101,7 +83,6 @@ module PM
|
|
|
101
83
|
end
|
|
102
84
|
|
|
103
85
|
# ---- Singleton instance per subclass ----------------------------------
|
|
104
|
-
# Created by register_all, accessible for tests and state management.
|
|
105
86
|
|
|
106
87
|
def instance
|
|
107
88
|
@instance
|
|
@@ -109,16 +90,17 @@ module PM
|
|
|
109
90
|
|
|
110
91
|
# ---- Dispatch block builder -------------------------------------------
|
|
111
92
|
# Override in subclasses to customize how directive methods are called.
|
|
112
|
-
# The default passes (ctx, *args) straight through
|
|
113
|
-
# PM::CoreDirectives whose methods use the RenderContext.
|
|
93
|
+
# The default passes (ctx, *args) straight through.
|
|
114
94
|
|
|
115
95
|
def build_dispatch_block(inst, method_name)
|
|
116
96
|
proc { |ctx, *args| inst.send(method_name, ctx, *args) }
|
|
117
97
|
end
|
|
118
98
|
|
|
119
99
|
# ---- PM registration --------------------------------------------------
|
|
120
|
-
# Creates one instance per subclass and registers every described
|
|
121
|
-
#
|
|
100
|
+
# Creates one instance per subclass and registers every described method
|
|
101
|
+
# (plus its aliases). Safe to call repeatedly: a subclass may re-register
|
|
102
|
+
# its own names, and a later subclass may override an earlier one. A name
|
|
103
|
+
# claimed with PM.register raises DirectiveAlreadyRegisteredError.
|
|
122
104
|
|
|
123
105
|
def register_all
|
|
124
106
|
PM::Directive.directive_subclasses.each do |klass|
|
|
@@ -129,15 +111,25 @@ module PM
|
|
|
129
111
|
|
|
130
112
|
klass.directive_descriptions.each_key do |method_name|
|
|
131
113
|
aliases = klass.directive_aliases[method_name] || []
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
# Remove previously registered names (idempotent re-init)
|
|
135
|
-
names.each { |n| PM.directives.delete(n.to_sym) }
|
|
136
|
-
|
|
137
|
-
block = klass.build_dispatch_block(inst, method_name)
|
|
138
|
-
PM.register(*names, &block)
|
|
114
|
+
block = klass.build_dispatch_block(inst, method_name)
|
|
115
|
+
PM.send(:register_from_class, klass, [method_name, *aliases], block)
|
|
139
116
|
end
|
|
140
117
|
end
|
|
118
|
+
nil
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
# Detects aliases created by alias_method: UnboundMethod#original_name
|
|
124
|
+
# differs from the method name when the method is an alias.
|
|
125
|
+
def record_alias(method_name)
|
|
126
|
+
um = instance_method(method_name)
|
|
127
|
+
original = um.original_name
|
|
128
|
+
return if original == method_name || !directive_descriptions.key?(original)
|
|
129
|
+
|
|
130
|
+
(directive_aliases[original] ||= []) << method_name
|
|
131
|
+
rescue NameError
|
|
132
|
+
# The method may reference constants that are not yet defined at load time.
|
|
141
133
|
end
|
|
142
134
|
end
|
|
143
135
|
end
|
data/lib/pm/directives.rb
CHANGED
|
@@ -2,32 +2,71 @@
|
|
|
2
2
|
|
|
3
3
|
module PM
|
|
4
4
|
# --- Directive registry ---
|
|
5
|
+
#
|
|
6
|
+
# Two kinds of registration share one namespace:
|
|
7
|
+
# PM.register(:name) { |ctx, *args| ... } -- block directives
|
|
8
|
+
# PM::Directive subclasses + register_all -- class directives
|
|
9
|
+
#
|
|
10
|
+
# Rules:
|
|
11
|
+
# * PM.register raises DirectiveAlreadyRegisteredError if ANY requested name
|
|
12
|
+
# is taken, and registers nothing in that case (atomic).
|
|
13
|
+
# * register_all may re-register a name that a Directive subclass owns
|
|
14
|
+
# (so it is idempotent and a later subclass can override an earlier one),
|
|
15
|
+
# but raises if the name was claimed with PM.register.
|
|
5
16
|
|
|
6
|
-
@directives
|
|
17
|
+
@directives = {}
|
|
18
|
+
@directive_owners = {} # name => the PM::Directive subclass that registered it
|
|
7
19
|
|
|
8
|
-
# Registers one or more
|
|
9
|
-
#
|
|
10
|
-
# followed by any arguments from the ERB call.
|
|
11
|
-
# Multiple names register the same block under each name (aliases).
|
|
12
|
-
# Raises RuntimeError if any name is already registered.
|
|
20
|
+
# Registers one or more names for the same block. The block receives a
|
|
21
|
+
# RenderContext first, then the arguments from the ERB call.
|
|
13
22
|
def self.register(*names, &block)
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
23
|
+
raise ArgumentError, 'PM.register requires a block' unless block
|
|
24
|
+
|
|
25
|
+
syms = names.map(&:to_sym)
|
|
26
|
+
taken = syms.find { |name| @directives.key?(name) }
|
|
27
|
+
raise DirectiveAlreadyRegisteredError, "Directive already registered: #{taken}" if taken
|
|
28
|
+
|
|
29
|
+
syms.each { |name| @directives[name] = block }
|
|
30
|
+
nil
|
|
21
31
|
end
|
|
22
32
|
|
|
23
|
-
#
|
|
33
|
+
# A frozen snapshot of the registry (name => block). Mutating it has no
|
|
34
|
+
# effect on PM; use PM.register / PM.reset_directives!.
|
|
24
35
|
def self.directives
|
|
25
|
-
@directives
|
|
36
|
+
@directives.dup.freeze
|
|
26
37
|
end
|
|
27
38
|
|
|
28
|
-
|
|
39
|
+
def self.directive?(name)
|
|
40
|
+
@directives.key?(name.to_sym)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Clears every directive, then re-registers the PM::Directive subclasses.
|
|
29
44
|
def self.reset_directives!
|
|
30
45
|
@directives.clear
|
|
46
|
+
@directive_owners.clear
|
|
31
47
|
PM::Directive.register_all
|
|
48
|
+
nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Used by PM::Directive.register_all. Not part of the public API.
|
|
52
|
+
def self.register_from_class(klass, names, block)
|
|
53
|
+
syms = names.map(&:to_sym)
|
|
54
|
+
|
|
55
|
+
syms.each do |name|
|
|
56
|
+
next unless @directives.key?(name)
|
|
57
|
+
if @directive_owners[name].nil?
|
|
58
|
+
raise DirectiveAlreadyRegisteredError,
|
|
59
|
+
"Directive already registered with PM.register: #{name}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
@directives.delete(name) # owned by a Directive subclass: later registration wins
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
syms.each do |name|
|
|
66
|
+
@directives[name] = block
|
|
67
|
+
@directive_owners[name] = klass
|
|
68
|
+
end
|
|
69
|
+
nil
|
|
32
70
|
end
|
|
71
|
+
private_class_method :register_from_class
|
|
33
72
|
end
|
data/lib/pm/errors.rb
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PM
|
|
4
|
+
# Base class for every error PM raises itself.
|
|
5
|
+
#
|
|
6
|
+
# Errors from the standard library pass through unchanged:
|
|
7
|
+
# Errno::ENOENT -- PM.parse given a file path that does not exist
|
|
8
|
+
# Psych::SyntaxError -- front matter that is not valid YAML
|
|
9
|
+
# Psych::DisallowedClass / Psych::AliasesNotEnabled -- YAML using symbols or anchors
|
|
10
|
+
# SyntaxError -- ERB that does not compile
|
|
11
|
+
class Error < StandardError; end
|
|
12
|
+
|
|
13
|
+
# Front matter was present but not a YAML mapping, or a known key
|
|
14
|
+
# (`shell`, `erb`, `parameters`) had a value of the wrong type.
|
|
15
|
+
class MetadataError < Error; end
|
|
16
|
+
|
|
17
|
+
# Shell expansion could not complete (for example an unmatched `$(`).
|
|
18
|
+
class ShellExpansionError < Error; end
|
|
19
|
+
|
|
20
|
+
# A `$(command)` exited non-zero. Carries the command, its Process::Status,
|
|
21
|
+
# and whatever it wrote to stderr.
|
|
22
|
+
class ShellCommandError < ShellExpansionError
|
|
23
|
+
attr_reader :command, :status, :stderr
|
|
24
|
+
|
|
25
|
+
def initialize(command, status, stderr = '')
|
|
26
|
+
@command = command
|
|
27
|
+
@status = status
|
|
28
|
+
@stderr = stderr.to_s
|
|
29
|
+
super(build_message)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def build_message
|
|
35
|
+
message = "Shell command failed (exit #{status.exitstatus}): #{command}"
|
|
36
|
+
detail = stderr.strip
|
|
37
|
+
detail.empty? ? message : "#{message}\n#{detail}"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Base for problems raised by the built-in `include` directive.
|
|
42
|
+
class IncludeError < Error; end
|
|
43
|
+
|
|
44
|
+
# A file tried to include one of its own ancestors.
|
|
45
|
+
class CircularIncludeError < IncludeError; end
|
|
46
|
+
|
|
47
|
+
# Nesting went past PM.config.max_include_depth.
|
|
48
|
+
class IncludeDepthError < IncludeError; end
|
|
49
|
+
|
|
50
|
+
# `include` was used from a string-parsed prompt that has no directory.
|
|
51
|
+
class IncludeContextError < IncludeError; end
|
|
52
|
+
|
|
53
|
+
# `insert` / `read` named a file that does not exist.
|
|
54
|
+
class FileNotFoundError < Error; end
|
|
55
|
+
|
|
56
|
+
# PM.register (or a PM::Directive subclass) tried to claim a name that is taken.
|
|
57
|
+
class DirectiveAlreadyRegisteredError < Error; end
|
|
58
|
+
|
|
59
|
+
# Parameter problems. Subclasses ArgumentError rather than PM::Error so that
|
|
60
|
+
# `rescue ArgumentError` written against 1.0.x keeps working. Rescue
|
|
61
|
+
# PM::ParameterError when you want only these.
|
|
62
|
+
class ParameterError < ArgumentError; end
|
|
63
|
+
|
|
64
|
+
# A parameter declared with a `null` default was not supplied to `to_s`.
|
|
65
|
+
# The message always starts with "Missing required parameters:".
|
|
66
|
+
class MissingParameterError < ParameterError; end
|
|
67
|
+
|
|
68
|
+
# Strict mode only: a template referenced a name that is neither a
|
|
69
|
+
# parameter nor a registered directive.
|
|
70
|
+
class UnknownParameterError < ParameterError; end
|
|
71
|
+
end
|
data/lib/pm/metadata.rb
CHANGED
|
@@ -1,17 +1,118 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'ostruct'
|
|
4
|
-
|
|
5
3
|
module PM
|
|
6
|
-
#
|
|
7
|
-
|
|
4
|
+
# Schemaless front-matter container with dot-notation access.
|
|
5
|
+
#
|
|
6
|
+
# meta = PM::Metadata.new('title' => 'Hi', shell: true)
|
|
7
|
+
# meta.title #=> "Hi"
|
|
8
|
+
# meta[:title] #=> "Hi"
|
|
9
|
+
# meta['title'] #=> "Hi"
|
|
10
|
+
# meta.shell? #=> true (predicates exist only for true/false values)
|
|
11
|
+
# meta.missing #=> nil
|
|
12
|
+
# meta.other = 1 # setter
|
|
13
|
+
# meta.to_h #=> {title: "Hi", shell: true, other: 1} (Symbol keys)
|
|
14
|
+
#
|
|
15
|
+
# Keys are stored as Symbols; `[]`, `[]=`, and `key?` accept Strings or Symbols.
|
|
16
|
+
# A key whose name collides with a public Object method (`hash`, `display`, ...)
|
|
17
|
+
# still works through dot notation because an accessor is defined for it.
|
|
18
|
+
class Metadata
|
|
19
|
+
include Enumerable
|
|
20
|
+
|
|
21
|
+
# Object methods that must never be shadowed by a metadata key.
|
|
22
|
+
PROTECTED_METHODS = %i[
|
|
23
|
+
class object_id send public_send __send__ __id__ respond_to? respond_to_missing?
|
|
24
|
+
method_missing instance_variable_get instance_variable_set instance_variables
|
|
25
|
+
is_a? kind_of? instance_of? freeze frozen? dup clone itself then tap
|
|
26
|
+
to_h to_s inspect == != equal? eql? each each_pair key? keys values
|
|
27
|
+
fetch dig delete empty? size length to_enum enum_for
|
|
28
|
+
].to_set.freeze
|
|
29
|
+
|
|
8
30
|
def initialize(hash = {})
|
|
9
|
-
|
|
10
|
-
hash.each
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
31
|
+
@table = {}
|
|
32
|
+
hash.each { |key, value| self[key] = value }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def [](key)
|
|
36
|
+
@table[key.to_sym]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def []=(key, value)
|
|
40
|
+
sym = key.to_sym
|
|
41
|
+
@table[sym] = value
|
|
42
|
+
define_accessor(sym) if shadows_object_method?(sym)
|
|
43
|
+
value
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def key?(key) = @table.key?(key.to_sym)
|
|
47
|
+
|
|
48
|
+
def keys = @table.keys
|
|
49
|
+
def values = @table.values
|
|
50
|
+
def size = @table.size
|
|
51
|
+
alias length size
|
|
52
|
+
def empty? = @table.empty?
|
|
53
|
+
|
|
54
|
+
def fetch(key, *default, &block) = @table.fetch(key.to_sym, *default, &block)
|
|
55
|
+
|
|
56
|
+
def dig(key, *rest)
|
|
57
|
+
value = self[key]
|
|
58
|
+
rest.empty? || value.nil? ? value : value.dig(*rest)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def delete(key) = @table.delete(key.to_sym)
|
|
62
|
+
|
|
63
|
+
def each(&block)
|
|
64
|
+
return to_enum(:each) unless block
|
|
65
|
+
|
|
66
|
+
@table.each(&block)
|
|
67
|
+
self
|
|
68
|
+
end
|
|
69
|
+
alias each_pair each
|
|
70
|
+
|
|
71
|
+
def to_h = @table.dup
|
|
72
|
+
|
|
73
|
+
def ==(other)
|
|
74
|
+
other.is_a?(Metadata) && to_h == other.to_h
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def inspect = "#<PM::Metadata #{@table.inspect}>"
|
|
78
|
+
alias to_s inspect
|
|
79
|
+
|
|
80
|
+
def method_missing(name, *args, &block)
|
|
81
|
+
key = name.to_s
|
|
82
|
+
|
|
83
|
+
if key.end_with?('=') && args.size == 1
|
|
84
|
+
self[key.chomp('=')] = args.first
|
|
85
|
+
elsif key.end_with?('?') && args.empty? && boolean_key?(key.chomp('?'))
|
|
86
|
+
self[key.chomp('?')]
|
|
87
|
+
elsif args.empty? && !key.end_with?('?', '!')
|
|
88
|
+
self[key] # nil for unknown keys, matching 1.0.x OpenStruct behaviour
|
|
89
|
+
else
|
|
90
|
+
super
|
|
14
91
|
end
|
|
15
92
|
end
|
|
93
|
+
|
|
94
|
+
def respond_to_missing?(name, include_private = false)
|
|
95
|
+
key = name.to_s
|
|
96
|
+
return true if key.end_with?('=')
|
|
97
|
+
return boolean_key?(key.chomp('?')) if key.end_with?('?')
|
|
98
|
+
return false if key.end_with?('!')
|
|
99
|
+
|
|
100
|
+
key?(key) || super
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def boolean_key?(key)
|
|
106
|
+
value = self[key]
|
|
107
|
+
value.equal?(true) || value.equal?(false)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def shadows_object_method?(sym)
|
|
111
|
+
!PROTECTED_METHODS.include?(sym) && Object.public_method_defined?(sym)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def define_accessor(sym)
|
|
115
|
+
define_singleton_method(sym) { @table[sym] }
|
|
116
|
+
end
|
|
16
117
|
end
|
|
17
118
|
end
|
data/lib/pm/parsed.rb
CHANGED
|
@@ -1,59 +1,64 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require '
|
|
4
|
-
require 'ostruct'
|
|
5
|
-
require 'erb'
|
|
3
|
+
require 'yaml'
|
|
6
4
|
|
|
7
5
|
module PM
|
|
8
|
-
#
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
6
|
+
# Passed as the first argument to every directive.
|
|
7
|
+
#
|
|
8
|
+
# directory -- directory of the file being rendered (nil for string prompts)
|
|
9
|
+
# params -- merged parameter values (String keys)
|
|
10
|
+
# included -- Set of ancestor file paths, used for circular-include detection
|
|
11
|
+
# depth -- include nesting depth (0 for the top-level prompt)
|
|
12
|
+
# metadata -- the current file's PM::Metadata
|
|
13
|
+
RenderContext = Data.define(:directory, :params, :included, :depth, :metadata) do
|
|
14
|
+
def initialize(directory: nil, params: {}, included: Set.new, depth: 0, metadata: nil)
|
|
15
|
+
super(directory: directory, params: params, included: included, depth: depth, metadata: metadata)
|
|
14
16
|
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Metadata keys PM adds itself when parsing a file, or fills in while rendering.
|
|
20
|
+
# They are left out of #to_markdown so a prompt round-trips cleanly.
|
|
21
|
+
DERIVED_METADATA_KEYS = %i[directory name created_at modified_at includes].freeze
|
|
22
|
+
|
|
23
|
+
# The result of PM.parse: front matter plus content with shell references
|
|
24
|
+
# already expanded. Call #to_s to render the ERB.
|
|
25
|
+
Parsed = Data.define(:metadata, :content) do
|
|
26
|
+
def [](key) = metadata[key]
|
|
27
|
+
|
|
28
|
+
def key?(key) = metadata.key?(key)
|
|
29
|
+
|
|
30
|
+
# The `parameters` mapping from the front matter ({} when absent).
|
|
31
|
+
def parameters = metadata.parameters || {}
|
|
15
32
|
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
33
|
+
# Renders the content with ERB. Registered directives and parameters are
|
|
34
|
+
# available as bare identifiers. Raises PM::MissingParameterError (an
|
|
35
|
+
# ArgumentError) if a `null`-default parameter is not supplied.
|
|
36
|
+
# With `erb: false` the content is returned untouched.
|
|
20
37
|
def to_s(values = {})
|
|
21
|
-
|
|
38
|
+
Renderer.new(self).render(values)
|
|
22
39
|
end
|
|
23
40
|
|
|
41
|
+
# Rendering entry point used by the `include` directive, which must pass
|
|
42
|
+
# the active include chain and depth through.
|
|
24
43
|
def render_with(values, included, depth)
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
directory: metadata.directory,
|
|
44
|
-
params: params,
|
|
45
|
-
included: included,
|
|
46
|
-
depth: depth,
|
|
47
|
-
metadata: metadata
|
|
48
|
-
)
|
|
49
|
-
|
|
50
|
-
PM.directives.each do |name, block|
|
|
51
|
-
context.define_singleton_method(name) do |*args|
|
|
52
|
-
block.call(render_ctx, *args)
|
|
53
|
-
end
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
ERB.new(content).result(context.instance_eval { binding })
|
|
44
|
+
Renderer.new(self).render(values, included: included, depth: depth)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def to_h
|
|
48
|
+
{ metadata: metadata.to_h, content: content }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The prompt as a markdown file again: YAML front matter followed by the
|
|
52
|
+
# content. Keys PM derived from the file system (directory, name, timestamps)
|
|
53
|
+
# and the `includes` tree are omitted. `shell` and `erb` are always written,
|
|
54
|
+
# since their values were resolved against the global config at parse time.
|
|
55
|
+
# Content is the parsed content, so shell references have already been expanded.
|
|
56
|
+
def to_markdown
|
|
57
|
+
authored = metadata.to_h.reject { |key, _| DERIVED_METADATA_KEYS.include?(key) }
|
|
58
|
+
return content if authored.empty?
|
|
59
|
+
|
|
60
|
+
yaml = YAML.dump(authored.transform_keys(&:to_s)).delete_prefix("---\n")
|
|
61
|
+
"---\n#{yaml}---\n#{content}"
|
|
57
62
|
end
|
|
58
63
|
end
|
|
59
64
|
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PM
|
|
4
|
+
# The object whose binding an ERB template is evaluated in.
|
|
5
|
+
#
|
|
6
|
+
# A bare identifier in a template resolves here, in this order:
|
|
7
|
+
# 1. a parameter of that name, when called with no arguments
|
|
8
|
+
# 2. a registered directive of that name (called with the RenderContext first)
|
|
9
|
+
# 3. nil -- or PM::UnknownParameterError when PM.config.strict_parameters is on
|
|
10
|
+
#
|
|
11
|
+
# So a parameter shadows a zero-argument directive call of the same name, and
|
|
12
|
+
# a directive call with arguments is never confused with a parameter.
|
|
13
|
+
class RenderScope
|
|
14
|
+
def initialize(params, directives, context, strict: false)
|
|
15
|
+
@params = params
|
|
16
|
+
@directives = directives
|
|
17
|
+
@context = context
|
|
18
|
+
@strict = strict
|
|
19
|
+
shadow_object_methods
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# The binding ERB renders in. A method rather than instance_eval so the
|
|
23
|
+
# only locals visible to the template are ERB's own.
|
|
24
|
+
def erb_binding = binding
|
|
25
|
+
|
|
26
|
+
def method_missing(name, *args, &block)
|
|
27
|
+
key = name.to_s
|
|
28
|
+
|
|
29
|
+
if args.empty? && @params.key?(key)
|
|
30
|
+
@params[key]
|
|
31
|
+
elsif (directive = @directives[name])
|
|
32
|
+
directive.call(@context, *args, &block)
|
|
33
|
+
elsif @strict
|
|
34
|
+
raise UnknownParameterError, "Unknown parameter or directive: #{name}"
|
|
35
|
+
elsif args.empty?
|
|
36
|
+
nil
|
|
37
|
+
else
|
|
38
|
+
super
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def respond_to_missing?(name, include_private = false)
|
|
43
|
+
@params.key?(name.to_s) || @directives.key?(name) || super
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
# Never shadowed: erb_binding calls `binding`.
|
|
49
|
+
UNSHADOWED = %i[binding].freeze
|
|
50
|
+
|
|
51
|
+
# A parameter named like an Object method -- public (`hash`, `display`)
|
|
52
|
+
# or private Kernel (`format`, `test`, `system`) -- would otherwise never
|
|
53
|
+
# reach method_missing. Called bare it is the parameter; called with
|
|
54
|
+
# arguments or a block it is still the method.
|
|
55
|
+
def shadow_object_methods
|
|
56
|
+
@params.each_key do |key|
|
|
57
|
+
sym = key.to_sym
|
|
58
|
+
next unless shadowable?(sym)
|
|
59
|
+
|
|
60
|
+
define_singleton_method(sym) do |*args, **kwargs, &block|
|
|
61
|
+
next @params[key] if args.empty? && kwargs.empty? && block.nil?
|
|
62
|
+
|
|
63
|
+
super(*args, **kwargs, &block)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def shadowable?(sym)
|
|
69
|
+
return false if Metadata::PROTECTED_METHODS.include?(sym) || UNSHADOWED.include?(sym)
|
|
70
|
+
|
|
71
|
+
Object.public_method_defined?(sym) || Object.private_method_defined?(sym)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
data/lib/pm/renderer.rb
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'erb'
|
|
4
|
+
|
|
5
|
+
module PM
|
|
6
|
+
# Turns a Parsed prompt into its final string: merges parameters, checks the
|
|
7
|
+
# required ones, builds the RenderContext, and evaluates ERB.
|
|
8
|
+
#
|
|
9
|
+
# `included` is the set of ancestor file paths (the active include chain).
|
|
10
|
+
# It is copied, never mutated, so sibling includes do not see each other.
|
|
11
|
+
class Renderer
|
|
12
|
+
def initialize(parsed)
|
|
13
|
+
@parsed = parsed
|
|
14
|
+
@metadata = parsed.metadata
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def render(values = {}, included: Set.new, depth: 0)
|
|
18
|
+
@metadata.includes = []
|
|
19
|
+
return @parsed.content unless @metadata.erb?
|
|
20
|
+
|
|
21
|
+
params = merge_params(values)
|
|
22
|
+
context = build_context(params, included, depth)
|
|
23
|
+
scope = RenderScope.new(params, PM.directives, context, strict: PM.config.strict_parameters?)
|
|
24
|
+
|
|
25
|
+
ERB.new(@parsed.content).result(scope.erb_binding)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def merge_params(values)
|
|
31
|
+
defaults = (@metadata.parameters || {}).transform_keys(&:to_s)
|
|
32
|
+
params = defaults.merge(values.transform_keys(&:to_s))
|
|
33
|
+
|
|
34
|
+
missing = params.select { |_, value| value.nil? }.keys
|
|
35
|
+
raise MissingParameterError, "Missing required parameters: #{missing.join(', ')}" unless missing.empty?
|
|
36
|
+
|
|
37
|
+
params
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def build_context(params, included, depth)
|
|
41
|
+
ancestors = included.dup
|
|
42
|
+
ancestors << own_path if own_path
|
|
43
|
+
|
|
44
|
+
RenderContext.new(
|
|
45
|
+
directory: @metadata.directory,
|
|
46
|
+
params: params,
|
|
47
|
+
included: ancestors,
|
|
48
|
+
depth: depth,
|
|
49
|
+
metadata: @metadata
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def own_path
|
|
54
|
+
return nil unless @metadata.directory && @metadata.name
|
|
55
|
+
|
|
56
|
+
File.join(@metadata.directory, @metadata.name)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|