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,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 (`&`), 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 `©` 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("&", "&").gsub("<", "<").gsub(">", ">").gsub("\u00A0", " ")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.attribute_escaper(quote)
|
|
65
|
+
lambda do |text|
|
|
66
|
+
escaped = text.gsub("&", "&").gsub("<", "<").gsub(">", ">")
|
|
67
|
+
quote == "'" ? escaped.gsub("'", "'") : escaped.gsub('"', """)
|
|
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
|