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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +15 -0
- data/LICENSE +20 -0
- data/README.md +196 -0
- data/app/assets/tensaku.js +85 -0
- data/lib/generators/tensaku/install_generator.rb +103 -0
- data/lib/tensaku/caches.rb +31 -0
- data/lib/tensaku/endpoints.rb +115 -0
- data/lib/tensaku/file_write.rb +33 -0
- data/lib/tensaku/guard.rb +77 -0
- data/lib/tensaku/i18n.rb +79 -0
- data/lib/tensaku/mcp.rb +135 -0
- data/lib/tensaku/middleware.rb +62 -0
- data/lib/tensaku/notes.rb +204 -0
- data/lib/tensaku/railtie.rb +36 -0
- data/lib/tensaku/source/html_text.rb +121 -0
- data/lib/tensaku/source/locale.rb +158 -0
- data/lib/tensaku/source/markdown.rb +78 -0
- data/lib/tensaku/source/template.rb +252 -0
- data/lib/tensaku/version.rb +3 -0
- data/lib/tensaku.rb +91 -0
- data/skills/tensaku/SKILL.md +61 -0
- metadata +126 -0
|
@@ -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
|
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: []
|