tensaku 0.0.1

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.
@@ -0,0 +1,252 @@
1
+ require "herb"
2
+ require "tensaku/source/html_text"
3
+
4
+ module Tensaku
5
+ module Source
6
+ # An ERB template, read through Herb, addressed by the element stamps Herb's
7
+ # SourceAttributionVisitor writes (`data-herb-source="path:line:col"`, 1-based).
8
+ #
9
+ # Pieces are the element's children as they are in the file: static text (editable), Ruby output
10
+ # and control flow (locked), child elements (locked, edited on their own) and comments (hidden).
11
+ class Template
12
+ PARSE_OPTIONS = { track_locations: true, action_view_helpers: true }.freeze
13
+ EXTENSIONS = %w[.erb].freeze
14
+
15
+ # Attributes people read. `value` only counts on buttons.
16
+ ATTRIBUTES = %w[alt title placeholder aria-label value].freeze
17
+ BUTTON_INPUTS = %w[submit button reset].freeze
18
+
19
+ OUTPUT_TAGS = %w[<%= <%==].freeze
20
+
21
+ attr_reader :relative_path, :source
22
+
23
+ def self.parse_stamp(stamp)
24
+ match = stamp.to_s.match(/\A(?<path>.+):(?<line>\d+):(?<column>\d+)\z/)
25
+ raise Error, "Not a source stamp: #{stamp.inspect}" unless match
26
+
27
+ [ match[:path], match[:line].to_i, match[:column].to_i ]
28
+ end
29
+
30
+ def self.from_stamp(stamp)
31
+ path, line, column = parse_stamp(stamp)
32
+ [ new(path), line, column ]
33
+ end
34
+
35
+ def initialize(relative_path)
36
+ @relative_path = relative_path
37
+ @path = Guard.writable_file!(relative_path, extensions: EXTENSIONS)
38
+ @source = File.read(@path, mode: "rb").force_encoding(Encoding::UTF_8)
39
+ @line_starts = [ 0 ]
40
+ @source.b.each_line { @line_starts << @line_starts.last + it.bytesize }
41
+ end
42
+
43
+ # What the overlay needs to edit or annotate the element written at line:column.
44
+ def describe(line, column)
45
+ element = element_at!(line, column)
46
+ helper = !element.open_tag.is_a?(Herb::AST::HTMLOpenTagNode)
47
+ pieces = helper ? [] : pieces_of(element)
48
+ {
49
+ file: relative_path,
50
+ stamp: "#{relative_path}:#{line}:#{column}",
51
+ range: range_label(element.location),
52
+ tag: tag_label(element),
53
+ helper:,
54
+ editable: !helper && pieces.any? { it[:kind] == "text" && !it[:text].strip.empty? },
55
+ pieces:,
56
+ attributes: helper ? [] : attributes_of(element)
57
+ }
58
+ end
59
+
60
+ # The file range of the element, for notes: "line:col-line:col".
61
+ def element_range(line, column)
62
+ element = element_at!(line, column)
63
+ { range: range_label(element.location), tag: tag_label(element) }
64
+ end
65
+
66
+ # edits: [{ start:, end:, original:, text: }], byte ranges of text pieces with the source they had
67
+ # when the page loaded, and the new text as shown in the browser.
68
+ def edit_text(line, column, edits)
69
+ element = element_at!(line, column)
70
+ pieces = pieces_of(element).select { it[:kind] == "text" }
71
+
72
+ replacements = edits.map do |edit|
73
+ piece = pieces.find { it[:start] == edit[:start].to_i && it[:end] == edit[:end].to_i }
74
+ stale! unless piece && piece[:raw] == edit[:original]
75
+
76
+ text = HtmlText.new(piece[:raw], trim_start: piece[:trim_start], trim_end: piece[:trim_end])
77
+ { start: piece[:start], end: piece[:end], text: text.rewrite(clean(edit[:text]), escape: HtmlText.method(:escape_text)) }
78
+ end
79
+
80
+ write(replacements)
81
+ end
82
+
83
+ def edit_attribute(line, column, name, edit)
84
+ element = element_at!(line, column)
85
+ attribute = attributes_of(element).find { it[:name] == name }
86
+ stale! unless attribute
87
+ raise Error, "#{name} has an unquoted value. Quote it in the template first." unless attribute[:quote]
88
+
89
+ piece = attribute[:pieces].find { it[:kind] == "text" && it[:start] == edit[:start].to_i && it[:end] == edit[:end].to_i }
90
+ stale! unless piece && piece[:raw] == edit[:original]
91
+
92
+ rewritten = HtmlText.new(piece[:raw], collapse: false).rewrite(clean(edit[:text]), escape: HtmlText.attribute_escaper(attribute[:quote]))
93
+ write([ { start: piece[:start], end: piece[:end], text: rewritten } ])
94
+ end
95
+
96
+ private
97
+
98
+ def element_at!(line, column)
99
+ finder = ElementFinder.new(line, column - 1)
100
+ Herb.parse(source, **PARSE_OPTIONS).value.accept(finder)
101
+ finder.found or stale!("No element starts at #{relative_path}:#{line}:#{column} any more. Reload the page.")
102
+ end
103
+
104
+ def stale!(message = nil)
105
+ raise StaleSource, message || "#{relative_path} changed since the page was rendered. Reload the page and try again."
106
+ end
107
+
108
+ def write(replacements)
109
+ replacements = replacements.reject { source.byteslice(it[:start]...it[:end]) == it[:text] }
110
+ return { changed: false } if replacements.empty?
111
+
112
+ FileWrite.atomic(@path, FileWrite.splice(source, replacements))
113
+ { changed: true, file: relative_path }
114
+ end
115
+
116
+ # Browsers hand back non-breaking spaces for typed runs of spaces in editable text.
117
+ def clean(text)
118
+ text.to_s.tr(" ", " ").delete("\r")
119
+ end
120
+
121
+ def pieces_of(element)
122
+ body = element.body
123
+ body.each_with_index.map do |node, index|
124
+ case node
125
+ when Herb::AST::HTMLTextNode
126
+ start, finish = byte_range(node.location)
127
+ raw = source.byteslice(start...finish)
128
+ trim_start = index.zero?
129
+ trim_end = index == body.size - 1
130
+ text = HtmlText.new(raw, trim_start:, trim_end:).text
131
+ { kind: "text", start:, end: finish, raw:, text:, trim_start:, trim_end: }
132
+ when Herb::AST::ERBContentNode
133
+ opening = node.tag_opening.value
134
+ if OUTPUT_TAGS.include?(opening)
135
+ { kind: "ruby", code: node.content.value.strip }
136
+ else
137
+ { kind: "hidden" }
138
+ end
139
+ when Herb::AST::HTMLElementNode
140
+ { kind: "element", tag: tag_label(node), label: label_of(node), stamp: stamp_of(node) }
141
+ when Herb::AST::HTMLCommentNode, Herb::AST::ERBYieldNode
142
+ { kind: "hidden" }
143
+ else
144
+ { kind: "ruby", code: summary_of(node) }
145
+ end
146
+ end
147
+ end
148
+
149
+ def attributes_of(element)
150
+ tag = element.tag_name&.value.to_s.downcase
151
+ nodes = element.open_tag.children.grep(Herb::AST::HTMLAttributeNode)
152
+ type = static_attribute(nodes, "type").to_s.downcase
153
+
154
+ nodes.filter_map do |node|
155
+ name = attribute_name(node)
156
+ next unless ATTRIBUTES.include?(name)
157
+ next if name == "value" && !(tag == "button" || (tag == "input" && BUTTON_INPUTS.include?(type)))
158
+ next unless node.value
159
+
160
+ quote = node.value.quoted ? node.value.open_quote&.value : nil
161
+ pieces = node.value.children.map do |child|
162
+ case child
163
+ when Herb::AST::LiteralNode
164
+ start, finish = byte_range(child.location)
165
+ raw = source.byteslice(start...finish)
166
+ { kind: "text", start:, end: finish, raw:, text: HtmlText.new(raw, collapse: false).text }
167
+ when Herb::AST::ERBContentNode
168
+ { kind: "ruby", code: child.content.value.strip }
169
+ else
170
+ { kind: "ruby", code: summary_of(child) }
171
+ end
172
+ end
173
+ { name:, quote:, pieces: }
174
+ end
175
+ end
176
+
177
+ def attribute_name(node)
178
+ node.name&.children&.filter_map { it.content if it.is_a?(Herb::AST::LiteralNode) }&.join&.downcase
179
+ end
180
+
181
+ def static_attribute(nodes, name)
182
+ node = nodes.find { attribute_name(it) == name }
183
+ node&.value&.children&.grep(Herb::AST::LiteralNode)&.map(&:content)&.join
184
+ end
185
+
186
+ def tag_label(element)
187
+ tag = element.tag_name&.value.to_s.downcase
188
+ return tag unless element.open_tag.respond_to?(:children)
189
+
190
+ nodes = element.open_tag.children.grep(Herb::AST::HTMLAttributeNode)
191
+ id = static_attribute(nodes, "id")
192
+ classes = static_attribute(nodes, "class").to_s.split.first
193
+ return "#{tag}##{id}" if id.present?
194
+ return "#{tag}.#{classes}" if classes.present?
195
+
196
+ tag
197
+ end
198
+
199
+ def label_of(element)
200
+ text = element.body.filter_map do |child|
201
+ case child
202
+ when Herb::AST::HTMLTextNode then child.content
203
+ when Herb::AST::HTMLElementNode then label_of(child)
204
+ when Herb::AST::ERBContentNode then "…" if OUTPUT_TAGS.include?(child.tag_opening.value)
205
+ end
206
+ end.join.squish
207
+ text.length > 40 ? "#{text[0, 39]}…" : text
208
+ end
209
+
210
+ def summary_of(node)
211
+ opening = node.respond_to?(:content) && node.content.respond_to?(:value) ? node.content.value : nil
212
+ (opening || node.class.name.demodulize).strip.lines.first.to_s.strip
213
+ end
214
+
215
+ def stamp_of(node)
216
+ start = node.open_tag.location.start
217
+ "#{relative_path}:#{start.line}:#{start.column + 1}"
218
+ end
219
+
220
+ # Herb positions are 1-based lines and 0-based character columns.
221
+ def byte_offset(position)
222
+ line_start = @line_starts.fetch(position.line - 1)
223
+ line = source.b[line_start...@line_starts.fetch(position.line, source.bytesize)].force_encoding(Encoding::UTF_8)
224
+ line_start + line[0, position.column].bytesize
225
+ end
226
+
227
+ def byte_range(location)
228
+ [ byte_offset(location.start), byte_offset(location.end) ]
229
+ end
230
+
231
+ def range_label(location)
232
+ "#{location.start.line}:#{location.start.column + 1}-#{location.end.line}:#{location.end.column + 1}"
233
+ end
234
+
235
+ class ElementFinder < Herb::Visitor
236
+ attr_reader :found
237
+
238
+ def initialize(line, column)
239
+ super()
240
+ @line = line
241
+ @column = column
242
+ end
243
+
244
+ def visit_html_element_node(node)
245
+ start = node.open_tag&.location&.start
246
+ @found ||= node if start && start.line == @line && start.column == @column
247
+ super unless @found
248
+ end
249
+ end
250
+ end
251
+ end
252
+ end
@@ -0,0 +1,3 @@
1
+ module Tensaku
2
+ VERSION = "0.0.1"
3
+ end
data/lib/tensaku.rb ADDED
@@ -0,0 +1,91 @@
1
+ require "securerandom"
2
+ require "tensaku/version"
3
+
4
+ # Tensaku edits text on a running Rails page and writes it back to the source, and keeps notes for
5
+ # coding agents. It does nothing outside development (see Tensaku.enabled?).
6
+ module Tensaku
7
+ # Loaded on first use, so an app that lists Tensaku in the wrong Gemfile group loads none of it
8
+ # (nor Herb) outside development.
9
+ autoload :Guard, "tensaku/guard"
10
+ autoload :FileWrite, "tensaku/file_write"
11
+ autoload :Middleware, "tensaku/middleware"
12
+ autoload :Endpoints, "tensaku/endpoints"
13
+ autoload :Notes, "tensaku/notes"
14
+ autoload :Mcp, "tensaku/mcp"
15
+ autoload :Caches, "tensaku/caches"
16
+ autoload :I18nMarkers, "tensaku/i18n"
17
+
18
+ module Source
19
+ autoload :HtmlText, "tensaku/source/html_text"
20
+ autoload :Template, "tensaku/source/template"
21
+ autoload :Locale, "tensaku/source/locale"
22
+ autoload :Markdown, "tensaku/source/markdown"
23
+ end
24
+
25
+ class Error < StandardError; end
26
+
27
+ # A write that would change the wrong bytes: the file changed since the page rendered, or the target
28
+ # moved. Nothing is merged or guessed.
29
+ class StaleSource < Error; end
30
+
31
+ # A request the guard refuses: not loopback, wrong token, or a path outside the allow-list.
32
+ class Forbidden < Error; end
33
+
34
+ class Config
35
+ # Directories, relative to Rails.root, that Tensaku may write to.
36
+ attr_reader :writable_paths
37
+
38
+ # Where note files live, relative to Rails.root.
39
+ attr_accessor :notes_path
40
+
41
+ # Resolved notes older than this are deleted.
42
+ attr_accessor :prune_after
43
+
44
+ # Keyboard shortcut that toggles the overlay, as modifier keys and a KeyboardEvent#code.
45
+ attr_accessor :shortcut
46
+
47
+ # Where the middleware serves the overlay and the endpoints.
48
+ attr_accessor :mount_path
49
+
50
+ def initialize
51
+ @writable_paths = %w[app/views app/components config/locales]
52
+ @notes_path = "tmp/tensaku/notes"
53
+ @prune_after = 3 * 24 * 60 * 60
54
+ @shortcut = "alt+shift+KeyT"
55
+ @mount_path = "/__tensaku"
56
+ end
57
+ end
58
+
59
+ class << self
60
+ def config
61
+ @config ||= Config.new
62
+ end
63
+
64
+ def configure
65
+ yield config
66
+ end
67
+
68
+ # Lets another gem allow writes to its own source directory, for example Kiln's content/.
69
+ def register_writable_path(path)
70
+ config.writable_paths << path.to_s unless config.writable_paths.include?(path.to_s)
71
+ end
72
+
73
+ def enabled?
74
+ defined?(Rails) && Rails.env.development?
75
+ end
76
+
77
+ # A per-boot secret embedded in the page. Write requests must echo it back.
78
+ def token
79
+ @token ||= SecureRandom.hex(16)
80
+ end
81
+
82
+ # The app root that relative source paths resolve against. Tests point it at a scratch copy.
83
+ attr_writer :root
84
+
85
+ def root
86
+ Pathname(@root || Rails.root)
87
+ end
88
+ end
89
+ end
90
+
91
+ require "tensaku/railtie" if defined?(Rails::Railtie)
@@ -0,0 +1,61 @@
1
+ ---
2
+ name: tensaku
3
+ description: Work through Tensaku notes, the comments a person left on pages of this Rails app in development, each pointing at the exact file and line that rendered the element. Use when the user mentions Tensaku, notes on the page, "my notes", or asks you to go through what they marked up in the browser.
4
+ ---
5
+
6
+ # Tensaku notes
7
+
8
+ Tensaku is a development-only overlay for this Rails app. The person clicks an element (or drags over an
9
+ area) on a running page and writes what should change. Each note records the page URL, the comment, and
10
+ one or more targets with the source position Herb stamped on the element.
11
+
12
+ ## Finding notes
13
+
14
+ Use whichever is available, in this order:
15
+
16
+ 1. **MCP**: the `tensaku` server (`/__tensaku/mcp` on the dev server). Tools: `list_notes`, `get_note`,
17
+ `resolve_note`, `ask_question`, `watch_notes`, `current_selection`. The dev server must be running.
18
+ 2. **Files**: `tmp/tensaku/notes/*.json`, one note per file. Read them directly when the server isn't
19
+ running. Edit `status` and `reply` in the file to resolve one.
20
+
21
+ A note looks like this:
22
+
23
+ ```json
24
+ {
25
+ "id": "a7f3",
26
+ "url": "/about",
27
+ "comment": "tighten this, and the heading is wrong",
28
+ "targets": [
29
+ { "kind": "element", "file": "app/views/pages/_hero.html.erb", "range": "4:3-4:40", "tag": "h1", "text": "About me",
30
+ "context": { "partials": ["app/views/pages/_hero.html.erb", "app/views/pages/about.html.erb"], "controller": "hero" } },
31
+ { "kind": "translation", "key": "pages.about.title", "locale": "en", "file": "config/locales/en.yml", "range": "12" }
32
+ ],
33
+ "rect": { "x": 120, "y": 340, "width": 600, "height": 180 },
34
+ "status": "open",
35
+ "reply": null
36
+ }
37
+ ```
38
+
39
+ - `range` is `line:column-line:column` in `file` (1-based). For translations it's the line of the key.
40
+ - `kind` is `element`, `text` (the person selected that text), `translation` (the text came from that
41
+ I18n key) or `markdown` (a Markdown source block).
42
+ - `rect` is set for area notes: the person dragged over a region, and `targets` lists the elements in it.
43
+ - An element rendered in a loop or a repeated partial has one source position for every copy.
44
+ - Text from Ruby (`<%= @post.title %>`) points at the ERB expression; the data itself lives elsewhere.
45
+
46
+ ## Working through notes
47
+
48
+ 1. List the open notes (`list_notes`, or read the files with `"status": "open"`).
49
+ 2. For each note, open the target file at the range, read the comment, and make the change. Look at
50
+ `context.partials` when the fix belongs in a parent template, and at the translation target when the
51
+ text comes from a locale file (change every locale that needs it).
52
+ 3. If the comment is ambiguous, don't guess: call `ask_question` with a short, specific question. The
53
+ question shows on the page and the note waits for an answer.
54
+ 4. When done, call `resolve_note` with a one-line reply that says what changed and where, for example
55
+ `Shortened the intro in app/views/pages/_hero.html.erb:6`. The pin on the page turns green so the
56
+ person can check it.
57
+
58
+ To keep going while the person adds notes, call `watch_notes` in a loop, passing the previous
59
+ `checked_at` as `since`.
60
+
61
+ You can take a screenshot of `url` with a browser tool if the layout matters; notes don't include one.
metadata ADDED
@@ -0,0 +1,126 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: tensaku
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - David Afonso
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: railties
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '8.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '8.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: actionpack
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '8.1'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '8.1'
40
+ - !ruby/object:Gem::Dependency
41
+ name: herb
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: 0.11.2
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: 0.11.2
54
+ - !ruby/object:Gem::Dependency
55
+ name: reactionview
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: 0.6.1
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: 0.6.1
68
+ description: Tensaku lets you click text on a Rails page in development and write
69
+ the change back to the ERB template, the locale YAML or the Markdown source, or
70
+ leave notes on elements and areas that a coding agent reads with the exact file
71
+ and line. Built on Herb through ReActionView. Development only.
72
+ email:
73
+ - dav@davafons.com
74
+ executables: []
75
+ extensions: []
76
+ extra_rdoc_files: []
77
+ files:
78
+ - CHANGELOG.md
79
+ - LICENSE
80
+ - README.md
81
+ - app/assets/tensaku.js
82
+ - lib/generators/tensaku/install_generator.rb
83
+ - lib/tensaku.rb
84
+ - lib/tensaku/caches.rb
85
+ - lib/tensaku/endpoints.rb
86
+ - lib/tensaku/file_write.rb
87
+ - lib/tensaku/guard.rb
88
+ - lib/tensaku/i18n.rb
89
+ - lib/tensaku/mcp.rb
90
+ - lib/tensaku/middleware.rb
91
+ - lib/tensaku/notes.rb
92
+ - lib/tensaku/railtie.rb
93
+ - lib/tensaku/source/html_text.rb
94
+ - lib/tensaku/source/locale.rb
95
+ - lib/tensaku/source/markdown.rb
96
+ - lib/tensaku/source/template.rb
97
+ - lib/tensaku/version.rb
98
+ - skills/tensaku/SKILL.md
99
+ homepage: https://github.com/davafons/tensaku
100
+ licenses:
101
+ - MIT
102
+ metadata:
103
+ homepage_uri: https://github.com/davafons/tensaku
104
+ source_code_uri: https://github.com/davafons/tensaku
105
+ changelog_uri: https://github.com/davafons/tensaku/blob/main/CHANGELOG.md
106
+ rubygems_mfa_required: 'true'
107
+ allowed_push_host: https://rubygems.org
108
+ rdoc_options: []
109
+ require_paths:
110
+ - lib
111
+ required_ruby_version: !ruby/object:Gem::Requirement
112
+ requirements:
113
+ - - ">="
114
+ - !ruby/object:Gem::Version
115
+ version: '4.0'
116
+ required_rubygems_version: !ruby/object:Gem::Requirement
117
+ requirements:
118
+ - - ">="
119
+ - !ruby/object:Gem::Version
120
+ version: '0'
121
+ requirements: []
122
+ rubygems_version: 4.0.20
123
+ specification_version: 4
124
+ summary: Edit text and leave notes for your coding agent on a running Rails page,
125
+ in development.
126
+ test_files: []