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,204 @@
1
+ require "json"
2
+ require "time"
3
+
4
+ module Tensaku
5
+ # Notes are one JSON file each in tmp/tensaku/notes: local, ignored by git, cleared by tmp:clear.
6
+ #
7
+ # A note is open until an agent (or you) resolves it with a reply, or sets it to `question` with a
8
+ # reply asking for more detail. Resolved notes are deleted after Tensaku.config.prune_after.
9
+ module Notes
10
+ STATUSES = %w[open question resolved].freeze
11
+ ID = /\A[a-z0-9]{4,12}\z/
12
+ TARGET_KINDS = %w[element text translation markdown].freeze
13
+
14
+ class NotFound < Error; end
15
+
16
+ module_function
17
+
18
+ def dir
19
+ Tensaku.root.join(Tensaku.config.notes_path)
20
+ end
21
+
22
+ def all
23
+ return [] unless dir.directory?
24
+
25
+ dir.glob("*.json").filter_map { read(it) }.sort_by { it["created_at"] }
26
+ end
27
+
28
+ def list(url: nil, status: nil)
29
+ statuses = Array(status).map(&:to_s)
30
+ all.select do |note|
31
+ (url.nil? || note["url"] == url) && (statuses.empty? || statuses.include?(note["status"]))
32
+ end
33
+ end
34
+
35
+ def find(id)
36
+ raise NotFound, "No note #{id.inspect}" unless id.to_s.match?(ID)
37
+
38
+ read(dir.join("#{id}.json")) or raise NotFound, "No note #{id.inspect}"
39
+ end
40
+
41
+ # attributes come from the overlay: url, comment, targets, and for area notes rect and viewport.
42
+ def create(attributes)
43
+ attributes = attributes.to_h.stringify_keys
44
+ comment = attributes["comment"].to_s.strip
45
+ raise Error, "A note needs a comment" if comment.empty?
46
+
47
+ now = Time.now.utc.iso8601
48
+ note = {
49
+ "id" => new_id,
50
+ "url" => attributes["url"].to_s,
51
+ "comment" => comment,
52
+ "targets" => Array(attributes["targets"]).map { target(it) },
53
+ "rect" => rect(attributes["rect"]),
54
+ "viewport" => attributes["viewport"]&.to_s,
55
+ "status" => "open",
56
+ "reply" => nil,
57
+ "created_at" => now,
58
+ "updated_at" => now
59
+ }.compact_blank.merge("reply" => nil)
60
+ write(note)
61
+ end
62
+
63
+ def update(id, status: nil, reply: nil, comment: nil)
64
+ note = find(id)
65
+ if status
66
+ raise Error, "Status must be one of #{STATUSES.join(", ")}" unless STATUSES.include?(status.to_s)
67
+
68
+ note["status"] = status.to_s
69
+ end
70
+ note["reply"] = reply.to_s.strip.presence unless reply.nil?
71
+ note["comment"] = comment.to_s.strip unless comment.to_s.strip.empty?
72
+ note["updated_at"] = Time.now.utc.iso8601
73
+ write(note)
74
+ end
75
+
76
+ def delete(id)
77
+ find(id)
78
+ dir.join("#{id}.json").delete
79
+ end
80
+
81
+ def prune!(now: Time.now)
82
+ cutoff = now - Tensaku.config.prune_after
83
+ all.each do |note|
84
+ next unless note["status"] == "resolved" && Time.iso8601(note["updated_at"]) < cutoff
85
+
86
+ dir.join("#{note["id"]}.json").delete
87
+ end
88
+ end
89
+
90
+ # Blocks until a note is created or changed after `since`, or the timeout passes. Returns the open
91
+ # notes that are new or changed. Polls the directory, which is plenty for one person and an agent.
92
+ def watch(since:, timeout: 60, interval: 0.5)
93
+ deadline = monotonic + timeout
94
+ loop do
95
+ fresh = all.select { it["status"] == "open" && Time.iso8601(it["updated_at"]) > since }
96
+ return fresh if fresh.any? || monotonic >= deadline
97
+
98
+ sleep interval
99
+ end
100
+ end
101
+
102
+ def to_markdown(notes)
103
+ notes = Array(notes)
104
+ return "No notes." if notes.empty?
105
+
106
+ notes.map { note_markdown(it) }.join("\n\n")
107
+ end
108
+
109
+ def note_markdown(note)
110
+ lines = [ "## Tensaku note #{note["id"]} (#{note["status"]}) on #{note["url"]}", "", note["comment"] ]
111
+ targets = Array(note["targets"])
112
+ if targets.any?
113
+ lines << ""
114
+ targets.each { lines << "- #{target_markdown(it)}" }
115
+ end
116
+ if (rect = note["rect"])
117
+ lines << "" << "Area: #{rect["width"]}x#{rect["height"]} at (#{rect["x"]}, #{rect["y"]}) in a #{note["viewport"] || "unknown"} viewport"
118
+ end
119
+ lines << "" << "Reply: #{note["reply"]}" if note["reply"]
120
+ lines.join("\n")
121
+ end
122
+
123
+ def target_markdown(target)
124
+ location = [ target["file"], target["range"] ].compact.join(":")
125
+ parts = [ target["kind"] ]
126
+ parts << "`#{target["tag"]}`" if target["tag"]
127
+ parts << "`#{target["key"]}` (#{target["locale"]})" if target["key"]
128
+ parts << "at `#{location}`" unless location.empty?
129
+ parts << "\"#{target["text"].to_s.squish.truncate(120)}\"" if target["text"].present?
130
+ context = target["context"].to_h.compact_blank
131
+ parts << "(#{context.map { |key, value| "#{key}: #{Array(value).join(" < ")}" }.join(", ")})" if context.any?
132
+ parts.join(" ")
133
+ end
134
+
135
+ # Fills in file positions from what the overlay saw: a Herb stamp, a translation key or a Markdown
136
+ # range. A target whose source can't be read keeps what the page said about it.
137
+ def target(raw)
138
+ raw = raw.to_h.stringify_keys
139
+ kind = TARGET_KINDS.include?(raw["kind"]) ? raw["kind"] : "element"
140
+ target = { "kind" => kind, "text" => raw["text"]&.to_s&.truncate(500), "tag" => raw["tag"]&.to_s }
141
+
142
+ if raw["stamp"].present?
143
+ target["stamp"] = raw["stamp"].to_s
144
+ path, line, column = Source::Template.parse_stamp(raw["stamp"])
145
+ target["file"] = path
146
+ target["range"] = "#{line}:#{column}"
147
+ begin
148
+ target.merge!(Source::Template.new(path).element_range(line, column).stringify_keys)
149
+ rescue Error
150
+ nil
151
+ end
152
+ end
153
+
154
+ if kind == "translation"
155
+ target["key"] = raw["key"].to_s
156
+ target["locale"] = raw["locale"].to_s
157
+ if (entry = Source::Locale.find(target["locale"], target["key"]))
158
+ target["file"] = entry[:file]
159
+ target["range"] = entry[:line].to_s
160
+ end
161
+ end
162
+
163
+ if kind == "markdown" && raw["md"].present?
164
+ target["md"] = raw["md"].to_s
165
+ target["file"], target["range"] = raw["md"].to_s.split(/:(?=\d+:\d+-)/, 2)
166
+ end
167
+
168
+ target["context"] = raw["context"].to_h.slice("partials", "controller", "frame").transform_values { Array(it).map(&:to_s).first(10) }.compact_blank
169
+ target.compact_blank
170
+ end
171
+
172
+ def rect(raw)
173
+ return nil unless raw
174
+
175
+ raw.to_h.stringify_keys.slice("x", "y", "width", "height").transform_values { it.to_f.round }
176
+ end
177
+
178
+ def new_id
179
+ loop do
180
+ id = SecureRandom.alphanumeric(4).downcase
181
+ return id unless dir.join("#{id}.json").exist?
182
+ end
183
+ end
184
+
185
+ def read(path)
186
+ JSON.parse(File.read(path))
187
+ rescue Errno::ENOENT, JSON::ParserError
188
+ nil
189
+ end
190
+
191
+ def write(note)
192
+ dir.mkpath
193
+ path = dir.join("#{note["id"]}.json")
194
+ temp = dir.join(".#{note["id"]}.json.tmp")
195
+ temp.write(JSON.pretty_generate(note) + "\n")
196
+ File.rename(temp, path)
197
+ note
198
+ end
199
+
200
+ def monotonic
201
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
202
+ end
203
+ end
204
+ end
@@ -0,0 +1,36 @@
1
+ module Tensaku
2
+ # Everything here is development only. In any other environment the railtie adds nothing, and
3
+ # neither ReActionView nor Herb is loaded, so templates keep rendering with Erubi.
4
+ class Railtie < Rails::Railtie
5
+ if Rails.env.development?
6
+ require "reactionview"
7
+ require "herb/engine/visitors/source_attribution_visitor"
8
+
9
+ # Before the app's initializers, so config/initializers/reactionview.rb can still adjust things.
10
+ initializer "tensaku.reactionview", before: :load_config_initializers do
11
+ ReActionView.configure do |config|
12
+ config.intercept_erb = true
13
+ config.engine.visitors.use(Herb::Engine::SourceAttributionVisitor.new)
14
+ end
15
+ end
16
+
17
+ initializer "tensaku.middleware" do |app|
18
+ app.middleware.insert_before 0, Tensaku::Middleware
19
+ end
20
+
21
+ initializer "tensaku.i18n" do
22
+ ActiveSupport.on_load(:action_controller_base) do
23
+ include Tensaku::I18nMarkers::Controller
24
+ end
25
+ end
26
+
27
+ config.after_initialize do
28
+ I18n.backend.extend(Tensaku::I18nMarkers::Backend) unless I18n.backend.is_a?(Tensaku::I18nMarkers::Backend)
29
+
30
+ if (warning = Tensaku::Caches.fragment_cache_warning)
31
+ Rails.logger.warn("[Tensaku] #{warning}")
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,121 @@
1
+ module Tensaku
2
+ module Source
3
+ # Static HTML text as the browser shows it, mapped back to its source bytes.
4
+ #
5
+ # The source is cut into segments: one per character, one per entity (`&amp;`), and one per run
6
+ # of whitespace (shown as a single space, or nothing at the edges of an element). An edit keeps the
7
+ # source of every segment the edit didn't touch, so `&copy;` or a line break in the middle of a
8
+ # paragraph survives an edit elsewhere in the same text.
9
+ class HtmlText
10
+ Segment = Data.define(:raw, :text)
11
+
12
+ ENTITIES = {
13
+ "amp" => "&", "lt" => "<", "gt" => ">", "quot" => '"', "apos" => "'", "nbsp" => "\u00A0",
14
+ "copy" => "\u00A9", "reg" => "\u00AE", "trade" => "\u2122", "hellip" => "\u2026",
15
+ "mdash" => "\u2014", "ndash" => "\u2013", "lsquo" => "\u2018", "rsquo" => "\u2019",
16
+ "ldquo" => "\u201C", "rdquo" => "\u201D", "laquo" => "\u00AB", "raquo" => "\u00BB",
17
+ "middot" => "\u00B7", "bull" => "\u2022", "euro" => "\u20AC", "pound" => "\u00A3",
18
+ "times" => "\u00D7", "divide" => "\u00F7", "deg" => "\u00B0", "shy" => "\u00AD",
19
+ "iexcl" => "\u00A1", "iquest" => "\u00BF", "larr" => "\u2190", "rarr" => "\u2192"
20
+ }.freeze
21
+
22
+ WHITESPACE = /\A[ \t\r\n\f]+\z/
23
+ TOKEN = /&(?:#[xX]\h+|#\d+|[a-zA-Z][a-zA-Z0-9]*);|[ \t\r\n\f]+|./m
24
+
25
+ attr_reader :segments
26
+
27
+ # collapse: whitespace runs read as one space (HTML text), or stay as written (attribute values).
28
+ # trim_start/trim_end: a whitespace run at that edge reads as nothing, as it does at the start or
29
+ # end of a block element.
30
+ def initialize(raw, collapse: true, trim_start: false, trim_end: false)
31
+ @raw = raw
32
+ tokens = raw.scan(TOKEN)
33
+ @segments = tokens.each_with_index.map do |token, index|
34
+ edge = (trim_start && index.zero?) || (trim_end && index == tokens.size - 1)
35
+ Segment.new(token, decode(token, collapse:, edge:))
36
+ end
37
+ end
38
+
39
+ def text
40
+ segments.map(&:text).join
41
+ end
42
+
43
+ # The new source for this text after an edit, changing only the segments the edit touched.
44
+ def rewrite(new_text, escape:)
45
+ old_text = text
46
+ return @raw if new_text == old_text
47
+
48
+ prefix = common_prefix(old_text, new_text)
49
+ suffix = common_suffix(old_text, new_text, limit: [ old_text.length, new_text.length ].min - prefix)
50
+
51
+ head = take_segments(segments, prefix)
52
+ tail = take_segments(segments.reverse, suffix).reverse
53
+ head_length = head.sum { it.text.length }
54
+ tail_length = tail.sum { it.text.length }
55
+ middle = new_text[head_length...(new_text.length - tail_length)]
56
+
57
+ head.map(&:raw).join + escape.call(middle) + tail.map(&:raw).join
58
+ end
59
+
60
+ def self.escape_text(text)
61
+ text.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;").gsub("\u00A0", "&nbsp;")
62
+ end
63
+
64
+ def self.attribute_escaper(quote)
65
+ lambda do |text|
66
+ escaped = text.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
67
+ quote == "'" ? escaped.gsub("'", "&#39;") : escaped.gsub('"', "&quot;")
68
+ end
69
+ end
70
+
71
+ private
72
+
73
+ def decode(token, collapse:, edge:)
74
+ if token.match?(WHITESPACE)
75
+ return token unless collapse
76
+
77
+ edge ? "" : " "
78
+ elsif token.start_with?("&") && token.length > 1
79
+ decode_entity(token)
80
+ else
81
+ token
82
+ end
83
+ end
84
+
85
+ def decode_entity(token)
86
+ name = token[1...-1]
87
+ code = if name.start_with?("#x", "#X") then name[2..].to_i(16)
88
+ elsif name.start_with?("#") then name[1..].to_i
89
+ end
90
+ return [ code ].pack("U") if code&.between?(1, 0x10FFFF)
91
+
92
+ ENTITIES.fetch(name, token)
93
+ end
94
+
95
+ def common_prefix(a, b)
96
+ length = 0
97
+ length += 1 while length < a.length && length < b.length && a[length] == b[length]
98
+ length
99
+ end
100
+
101
+ def common_suffix(a, b, limit:)
102
+ length = 0
103
+ length += 1 while length < limit && a[-1 - length] == b[-1 - length]
104
+ length
105
+ end
106
+
107
+ # The leading segments whose text fits entirely within `length` characters.
108
+ def take_segments(list, length)
109
+ taken = []
110
+ used = 0
111
+ list.each do |segment|
112
+ break if used + segment.text.length > length
113
+
114
+ used += segment.text.length
115
+ taken << segment
116
+ end
117
+ taken
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,158 @@
1
+ require "psych"
2
+
3
+ module Tensaku
4
+ module Source
5
+ # Locale YAML, edited without load-and-dump: Psych's parse tree gives each scalar's position, and
6
+ # only the value's bytes change. Comments, ordering and quoting stay as they were. Every write is
7
+ # checked by loading the result and comparing the edited value before the file is replaced.
8
+ module Locale
9
+ EXTENSIONS = %w[.yml .yaml].freeze
10
+ INTERPOLATION = /%\{[^}]+\}/
11
+
12
+ Entry = Data.define(:file, :path, :source, :node, :value) do
13
+ def line = node.start_line + 1
14
+
15
+ def to_h
16
+ { file:, line:, value:, style: Locale.style_name(node.style), editable: true }
17
+ end
18
+ end
19
+
20
+ module_function
21
+
22
+ # The file that defines locale.key, the last one in I18n.load_path order (the one that wins).
23
+ # Only files inside Tensaku's writable paths count.
24
+ def find(locale, key)
25
+ candidates(locale).reverse_each do |relative, path|
26
+ source = File.read(path, mode: "rb").force_encoding(Encoding::UTF_8)
27
+ node = scalar_at(Psych.parse(source), [ locale.to_s, *key.to_s.split(".") ])
28
+ return Entry.new(relative, path, source, node, node.value) if node
29
+ rescue Psych::SyntaxError
30
+ next
31
+ end
32
+ nil
33
+ end
34
+
35
+ # Every available locale's entry for the key, for the other-locales view.
36
+ def lookup_all(key)
37
+ I18n.available_locales.to_h do |locale|
38
+ entry = find(locale, key)
39
+ [ locale.to_s, entry&.to_h || { value: I18n.t(key, locale:, default: nil)&.then { it.is_a?(String) ? it : nil }, editable: false } ]
40
+ end
41
+ end
42
+
43
+ # Replaces the value of locale.key. `original` is the value the page was rendered with.
44
+ def edit(locale, key, original:, text:)
45
+ text = text.to_s.delete("\r")
46
+ entry = find(locale, key) or raise Error, "#{locale}.#{key} isn't defined in a writable locale file"
47
+ raise StaleSource, "#{entry.file} changed since the page was rendered. Reload the page and try again." unless entry.value == original.to_s
48
+
49
+ if text.scan(INTERPOLATION).sort != entry.value.scan(INTERPOLATION).sort
50
+ raise Error, "Keep the same %{...} placeholders: #{entry.value.scan(INTERPOLATION).join(", ")}"
51
+ end
52
+ return { changed: false, file: entry.file } if text == entry.value
53
+
54
+ start, finish = byte_range(entry.source, entry.node)
55
+ replacement = render(entry, text, entry.source.byteslice(start...finish))
56
+ updated = FileWrite.splice(entry.source, [ { start:, end: finish, text: replacement } ])
57
+
58
+ check!(updated, locale, key, text, entry.file)
59
+ FileWrite.atomic(entry.path, updated)
60
+ { changed: true, file: entry.file, line: entry.line }
61
+ end
62
+
63
+ def style_name(style)
64
+ { Psych::Nodes::Scalar::PLAIN => "plain", Psych::Nodes::Scalar::SINGLE_QUOTED => "single",
65
+ Psych::Nodes::Scalar::DOUBLE_QUOTED => "double", Psych::Nodes::Scalar::LITERAL => "literal",
66
+ Psych::Nodes::Scalar::FOLDED => "folded" }.fetch(style, "plain")
67
+ end
68
+
69
+ def candidates(locale)
70
+ allowed = Guard.writable_dirs.map(&:to_s)
71
+ root = Tensaku.root.realpath
72
+ I18n.load_path.flatten.filter_map do |entry|
73
+ path = Pathname(entry.to_s).expand_path
74
+ next unless EXTENSIONS.include?(path.extname) && path.file?
75
+
76
+ path = path.realpath
77
+ next unless allowed.any? { path.to_s.start_with?("#{it}/") }
78
+
79
+ [ path.relative_path_from(root).to_s, path ]
80
+ end.uniq.select { |_, path| File.read(path).include?(locale.to_s) }
81
+ end
82
+
83
+ def scalar_at(document, keys)
84
+ node = document&.root
85
+ keys.each do |key|
86
+ return nil unless node.is_a?(Psych::Nodes::Mapping)
87
+
88
+ pair = node.children.each_slice(2).to_a.reverse.find { |name, _| name.is_a?(Psych::Nodes::Scalar) && name.value == key }
89
+ return nil unless pair
90
+
91
+ node = pair.last
92
+ end
93
+ node if node.is_a?(Psych::Nodes::Scalar)
94
+ end
95
+
96
+ # Psych positions are 0-based lines and character columns.
97
+ def byte_range(source, node)
98
+ lines = source.lines
99
+ offset = ->(line, column) { lines.first(line).sum(&:bytesize) + lines.fetch(line, "")[0, column].to_s.bytesize }
100
+ [ offset.call(node.start_line, node.start_column), offset.call(node.end_line, node.end_column) ]
101
+ end
102
+
103
+ # The new scalar in the style the old one had. A plain scalar that would read differently as
104
+ # plain YAML (a leading quote, `: `, ` #`, `true`, a number) is written double-quoted instead.
105
+ def render(entry, text, old)
106
+ case entry.node.style
107
+ when Psych::Nodes::Scalar::SINGLE_QUOTED
108
+ text.include?("\n") ? double_quoted(text) : "'#{text.gsub("'", "''")}'"
109
+ when Psych::Nodes::Scalar::DOUBLE_QUOTED
110
+ double_quoted(text)
111
+ when Psych::Nodes::Scalar::LITERAL, Psych::Nodes::Scalar::FOLDED
112
+ block(entry, text, old)
113
+ else
114
+ plain_safe?(text) ? text : double_quoted(text)
115
+ end
116
+ end
117
+
118
+ def double_quoted(text)
119
+ escaped = text.gsub("\\", "\\\\\\\\").gsub('"', '\"').gsub("\n", "\\n").gsub("\t", "\\t")
120
+ %("#{escaped}")
121
+ end
122
+
123
+ def plain_safe?(text)
124
+ return false if text.empty? || text.include?("\n") || text != text.strip
125
+ return false if text.match?(/\A[-?:,\[\]{}#&*!|>'"%@`]/) || text.include?(": ") || text.include?(" #") || text.end_with?(":")
126
+
127
+ Psych.safe_load("v: #{text}")["v"] == text
128
+ rescue Psych::Exception
129
+ false
130
+ end
131
+
132
+ # Keeps the header (`>-`, `|+`), the indentation and any trailing blank lines of a block scalar.
133
+ def block(entry, text, old)
134
+ header, body = old.split("\n", 2)
135
+ indent = body.to_s[/\A[ ]+/] || " "
136
+ trailing = body.to_s[/\n*\z/].to_s
137
+ lines = if entry.node.style == Psych::Nodes::Scalar::FOLDED
138
+ text.split("\n", -1)
139
+ else
140
+ text.chomp.split("\n", -1)
141
+ end
142
+ content = lines.map { it.empty? ? "" : "#{indent}#{it}" }.join("\n")
143
+ "#{header}\n#{content}#{trailing.empty? ? "" : trailing}"
144
+ end
145
+
146
+ def check!(updated, locale, key, text, file)
147
+ loaded = Psych.safe_load(updated, aliases: true, permitted_classes: [ Symbol, Date, Time ])
148
+ value = [ locale.to_s, *key.to_s.split(".") ].reduce(loaded) { |node, name| node.is_a?(Hash) ? node[name] : nil }
149
+ expected = value.is_a?(String) && value.end_with?("\n") && !text.end_with?("\n") ? value.chomp : value
150
+ return if expected == text
151
+
152
+ raise Error, "Tensaku couldn't write that value to #{file} without changing its meaning. Edit the file directly."
153
+ rescue Psych::Exception => error
154
+ raise Error, "The edit would break #{file} (#{error.message}). Nothing was written."
155
+ end
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,78 @@
1
+ module Tensaku
2
+ module Source
3
+ # Markdown blocks, addressed by the ranges a producer writes:
4
+ # data-tensaku-md="path:startLine:startCol-endLine:endCol", 1-based lines and byte columns, end
5
+ # column inclusive (commonmarker's sourcepos). Tensaku doesn't render Markdown; it replaces text
6
+ # inside a block's range and leaves the rest of the file alone.
7
+ class Markdown
8
+ EXTENSIONS = %w[.md .markdown].freeze
9
+
10
+ # Block syntax at the start of a range that stays locked: heading hashes, list markers, quotes.
11
+ PREFIX = /\A(?:\#{1,6}[ \t]+|[-*+][ \t]+(?:\[[ xX]\][ \t]+)?|\d{1,9}[.)][ \t]+|>[ \t]?)/
12
+
13
+ # Block tags whose text is edited; others (lists, tables, code) only get notes.
14
+ EDITABLE = %w[p h1 h2 h3 h4 h5 h6 li blockquote].freeze
15
+
16
+ def self.parse_range(value)
17
+ match = value.to_s.match(/\A(?<path>.+):(?<sl>\d+):(?<sc>\d+)-(?<el>\d+):(?<ec>\d+)\z/)
18
+ raise Error, "Not a Markdown range: #{value.inspect}" unless match
19
+
20
+ [ match[:path], *%i[sl sc el ec].map { match[it].to_i } ]
21
+ end
22
+
23
+ attr_reader :relative_path
24
+
25
+ def initialize(range)
26
+ @relative_path, @start_line, @start_column, @end_line, @end_column = self.class.parse_range(range)
27
+ @path = Guard.writable_file!(@relative_path, extensions: EXTENSIONS)
28
+ @source = File.read(@path, mode: "rb").force_encoding(Encoding::UTF_8)
29
+ end
30
+
31
+ def describe(tag: nil)
32
+ raw = block_source
33
+ prefix = raw[PREFIX].to_s
34
+ {
35
+ file: relative_path,
36
+ range: "#{@start_line}:#{@start_column}-#{@end_line}:#{@end_column}",
37
+ editable: tag.nil? || EDITABLE.include?(tag.to_s.downcase),
38
+ prefix:,
39
+ text: raw.delete_prefix(prefix),
40
+ original: raw
41
+ }
42
+ end
43
+
44
+ # original: the block's whole source when the editor opened. text: the new source after the
45
+ # locked prefix.
46
+ def edit(original:, text:)
47
+ @source = File.read(@path, mode: "rb").force_encoding(Encoding::UTF_8)
48
+ raw = block_source
49
+ raise StaleSource, "#{relative_path} changed since the page was rendered. Reload the page and try again." unless raw == original.to_s
50
+
51
+ replacement = raw[PREFIX].to_s + text.to_s.tr(" ", " ").delete("\r")
52
+ return { changed: false, file: relative_path } if replacement == raw
53
+
54
+ start, finish = byte_range
55
+ FileWrite.atomic(@path, FileWrite.splice(@source, [ { start:, end: finish, text: replacement } ]))
56
+ { changed: true, file: relative_path }
57
+ end
58
+
59
+ private
60
+
61
+ def block_source
62
+ start, finish = byte_range
63
+ @source.byteslice(start...finish).force_encoding(Encoding::UTF_8)
64
+ end
65
+
66
+ def byte_range
67
+ lines = @source.b.lines
68
+ raise StaleSource, "#{relative_path} is shorter than the page says. Reload the page." if @end_line > lines.size
69
+
70
+ offset = ->(line) { lines.first(line - 1).sum(&:bytesize) }
71
+ start = offset.call(@start_line) + @start_column - 1
72
+ last_line = lines[@end_line - 1].chomp
73
+ finish = offset.call(@end_line) + [ @end_column, last_line.bytesize ].min
74
+ [ start, finish ]
75
+ end
76
+ end
77
+ end
78
+ end