notion_publish 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +36 -0
  3. data/LICENSE +21 -0
  4. data/README.md +221 -0
  5. data/docs/notion-publish-home-page.md +105 -0
  6. data/docs/usage.md +648 -0
  7. data/exe/notion-publish +6 -0
  8. data/lib/notion_publish/adopter.rb +77 -0
  9. data/lib/notion_publish/cli.rb +212 -0
  10. data/lib/notion_publish/client.rb +226 -0
  11. data/lib/notion_publish/commands/adopt.rb +114 -0
  12. data/lib/notion_publish/commands/command.rb +114 -0
  13. data/lib/notion_publish/commands/properties.rb +33 -0
  14. data/lib/notion_publish/commands/publish.rb +134 -0
  15. data/lib/notion_publish/commands/relink.rb +95 -0
  16. data/lib/notion_publish/commands/reporting.rb +57 -0
  17. data/lib/notion_publish/commands/republish.rb +156 -0
  18. data/lib/notion_publish/commands/status.rb +84 -0
  19. data/lib/notion_publish/commands/whoami.rb +27 -0
  20. data/lib/notion_publish/commands.rb +17 -0
  21. data/lib/notion_publish/decoration.rb +75 -0
  22. data/lib/notion_publish/document.rb +75 -0
  23. data/lib/notion_publish/errors.rb +62 -0
  24. data/lib/notion_publish/fixups.rb +139 -0
  25. data/lib/notion_publish/links.rb +65 -0
  26. data/lib/notion_publish/log.rb +49 -0
  27. data/lib/notion_publish/manifest.rb +224 -0
  28. data/lib/notion_publish/media.rb +90 -0
  29. data/lib/notion_publish/notion_digest.rb +23 -0
  30. data/lib/notion_publish/pool.rb +103 -0
  31. data/lib/notion_publish/progress.rb +103 -0
  32. data/lib/notion_publish/property_set.rb +116 -0
  33. data/lib/notion_publish/publisher.rb +475 -0
  34. data/lib/notion_publish/reference.rb +84 -0
  35. data/lib/notion_publish/resolver.rb +216 -0
  36. data/lib/notion_publish/schema.rb +206 -0
  37. data/lib/notion_publish/settings.rb +73 -0
  38. data/lib/notion_publish/sharing.rb +47 -0
  39. data/lib/notion_publish/status.rb +127 -0
  40. data/lib/notion_publish/target.rb +34 -0
  41. data/lib/notion_publish/uploader.rb +85 -0
  42. data/lib/notion_publish/users.rb +49 -0
  43. data/lib/notion_publish/version.rb +5 -0
  44. data/lib/notion_publish.rb +31 -0
  45. metadata +96 -0
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+ require "pathname"
5
+ require "yaml"
6
+
7
+ require_relative "errors"
8
+
9
+ module NotionPublish
10
+ # Where each document was published, and enough about it to tell whether
11
+ # republishing has anything to do.
12
+ #
13
+ # Tool-written, never hand-edited, committed alongside the documents. One per
14
+ # repository so that links between documents in different directories can be
15
+ # resolved from a single place.
16
+ class Manifest
17
+ FILENAME = "notion-publish-manifest.yml"
18
+
19
+ HEADER = <<~TEXT
20
+ # Generated by notion-publish. Do not edit by hand.
21
+ # Maps each Markdown source to the Notion page it mirrors.
22
+ # To re-point an entry, delete it and publish again.
23
+ # Settings belong in .notion-publish.yml, which this tool never rewrites.
24
+ TEXT
25
+
26
+ ENTRY_FIELDS = %w[id url parent properties flag_properties title_override keep_h1 source_sha256
27
+ properties_sha256 flag_properties_sha256 notion_sha256 published_at].freeze
28
+
29
+ # One published document.
30
+ #
31
+ # +flag_properties+ names the properties whose values came from
32
+ # --property or --properties-json rather than front matter. Their values
33
+ # are not recorded, so `republish` leaves them alone. It is nil on entries
34
+ # written before it existed, which is how `republish` recognises them.
35
+ # +title_override+ and +keep_h1+ record --title and --keep-h1 for the
36
+ # same reason.
37
+ Entry = Data.define(:id, :url, :parent, :properties, :flag_properties, :title_override, :keep_h1,
38
+ :source_sha256, :properties_sha256, :flag_properties_sha256,
39
+ :notion_sha256, :published_at) do
40
+ # Only the page itself is required. Everything else accumulates: adopting
41
+ # records no source hash, and older entries predate later fields.
42
+ def initialize(id:, url:, parent: nil, properties: [], flag_properties: nil, title_override: nil,
43
+ keep_h1: nil, source_sha256: nil, properties_sha256: nil, flag_properties_sha256: nil,
44
+ notion_sha256: nil, published_at: nil)
45
+ super
46
+ end
47
+
48
+ def to_h
49
+ ENTRY_FIELDS.to_h { |field| [field, public_send(field)] }.compact
50
+ end
51
+
52
+ def self.from(hash)
53
+ new(**ENTRY_FIELDS.to_h { |field| [field.to_sym, hash[field]] }, properties: hash["properties"] || [])
54
+ end
55
+ end
56
+
57
+ attr_reader :path
58
+
59
+ # --manifest wins; then an existing manifest walking up from the
60
+ # document; then the repository root; then the document's own directory.
61
+ def self.locate(document_path, override: nil)
62
+ locate_in(File.dirname(File.expand_path(document_path)), override: override)
63
+ end
64
+
65
+ # The same search, starting from a directory rather than a document.
66
+ def self.locate_in(dir, override: nil)
67
+ return new(File.expand_path(override)) if override
68
+
69
+ found = walk_up(dir) { |d| File.file?(File.join(d, FILENAME)) }
70
+ return new(File.join(found, FILENAME)) if found
71
+
72
+ root = walk_up(dir) { |d| File.directory?(File.join(d, ".git")) }
73
+ new(File.join(root || dir, FILENAME))
74
+ end
75
+
76
+ def self.walk_up(dir)
77
+ loop do
78
+ return dir if yield(dir)
79
+
80
+ parent = File.dirname(dir)
81
+ return nil if parent == dir
82
+
83
+ dir = parent
84
+ end
85
+ end
86
+
87
+ def initialize(path)
88
+ @path = path
89
+ @data = File.file?(path) ? load_file : {}
90
+ @created = !File.file?(path)
91
+ # Keys removed in memory, so the merge with disk does not put them back.
92
+ @forgotten = []
93
+ # Several pages are checked and recorded at once; the file lock guards
94
+ # other processes, this guards the threads of this one.
95
+ @lock = Monitor.new
96
+ end
97
+
98
+ def dir = File.dirname(path)
99
+ def created? = @created
100
+ def pages = @data["pages"] ||= {}
101
+ def workspace_id = @data["workspace_id"]
102
+
103
+ def workspace_id=(value)
104
+ @lock.synchronize do
105
+ @data["workspace_id"] = value if value && @data["workspace_id"] != value
106
+ end
107
+ end
108
+
109
+ def key_for(absolute_path)
110
+ Pathname.new(File.expand_path(absolute_path)).relative_path_from(Pathname.new(dir)).to_s
111
+ rescue ArgumentError
112
+ File.expand_path(absolute_path)
113
+ end
114
+
115
+ def entry(absolute_path)
116
+ @lock.synchronize do
117
+ raw = pages[key_for(absolute_path)]
118
+ raw && Entry.from(raw)
119
+ end
120
+ end
121
+
122
+ def url_for(absolute_path) = entry(absolute_path)&.url
123
+
124
+ # Recording a key that was forgotten earlier in this run takes it off the
125
+ # forgotten list, or the save would delete the entry it is writing.
126
+ def record(absolute_path, entry)
127
+ @lock.synchronize do
128
+ key = key_for(absolute_path)
129
+ @forgotten.delete(key)
130
+ pages[key] = entry.to_h
131
+ save
132
+ end
133
+ end
134
+
135
+ def forget(absolute_path)
136
+ @lock.synchronize do
137
+ key = key_for(absolute_path)
138
+ pages.delete(key)
139
+ @forgotten << key
140
+ save
141
+ end
142
+ end
143
+
144
+ # Entries whose source file no longer exists. Reported, never acted on: the
145
+ # tool cannot tell a retirement from a rename.
146
+ def orphans
147
+ pages.keys.reject { |key| File.file?(File.expand_path(key, dir)) }
148
+ end
149
+
150
+ def writable?
151
+ return File.writable?(path) if File.exist?(path)
152
+
153
+ File.directory?(dir) && File.writable?(dir)
154
+ end
155
+
156
+ # Read-modify-write under an exclusive lock, so parallel invocations across a
157
+ # corpus cannot drop each other's entries.
158
+ def save
159
+ @lock.synchronize do
160
+ raise Error, unwritable_message unless writable?
161
+
162
+ # Lock the manifest itself rather than a sidecar: a stray .lock file next to
163
+ # committed state is litter, and one more thing to gitignore.
164
+ File.open(path, File::RDWR | File::CREAT, 0o644) do |file|
165
+ file.flock(File::LOCK_EX)
166
+ merged = merge_with_disk
167
+ file.rewind
168
+ file.truncate(0)
169
+ file.write(HEADER + YAML.dump(sorted(merged)))
170
+ @data = merged
171
+ end
172
+ @created = false
173
+ end
174
+ end
175
+
176
+ private
177
+
178
+ def merge_with_disk
179
+ on_disk = File.file?(path) ? load_file : {}
180
+ merged = on_disk.merge(@data)
181
+ merged["pages"] = (on_disk["pages"] || {}).merge(@data["pages"] || {})
182
+ @forgotten.each { |key| merged["pages"].delete(key) }
183
+ merged.compact
184
+ end
185
+
186
+ # Sorted keys keep a one-document publish to a one-entry diff.
187
+ def sorted(data)
188
+ out = {}
189
+ out["workspace_id"] = data["workspace_id"] if data["workspace_id"]
190
+ out["pages"] = data["pages"].sort.to_h if data["pages"] && !data["pages"].empty?
191
+ out
192
+ end
193
+
194
+ def load_file
195
+ loaded = YAML.safe_load_file(path, permitted_classes: [], aliases: false)
196
+ raise ConfigError, malformed_message("expected a YAML mapping") unless loaded.nil? || loaded.is_a?(Hash)
197
+
198
+ loaded || {}
199
+ rescue Psych::Exception => e
200
+ raise ConfigError, malformed_message(e.message)
201
+ end
202
+
203
+ # Refuse rather than starting from an empty manifest, which would republish
204
+ # everything as new and duplicate the lot.
205
+ def malformed_message(detail)
206
+ <<~MSG.strip
207
+ #{path} could not be read: #{detail}
208
+
209
+ Publishing has stopped.
210
+ Starting from an empty manifest would duplicate every published document.
211
+ Fix the file, or delete it and re-adopt the existing pages deliberately.
212
+ MSG
213
+ end
214
+
215
+ def unwritable_message
216
+ <<~MSG.strip
217
+ Cannot write #{path}.
218
+
219
+ Checked before publishing on purpose: creating pages and then failing to
220
+ record them is the one failure that cannot be retried safely.
221
+ MSG
222
+ end
223
+ end
224
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NotionPublish
4
+ # Finds local image references in a Markdown body.
5
+ #
6
+ # Notion's `markdown` parameter can only express *external* file references.
7
+ # A local path, an upload id, or anything else produces an image block with an
8
+ # empty URL -- and it does so without an error, so a dropped diagram looks
9
+ # like a successful publish. Local images therefore have to be uploaded and
10
+ # attached through the block API instead.
11
+ #
12
+ # To keep the server-side Markdown parser for everything else, each local
13
+ # image is swapped for a sentinel paragraph before publishing. The sentinel is
14
+ # located afterwards and the real image block is inserted in its place.
15
+ class Media
16
+ FENCE = /\A\s*(?:`{3,}|~{3,})/
17
+ # An image alone on its line. Only these can become blocks: Notion has no
18
+ # inline image, so an image sitting inside a sentence has nowhere to go.
19
+ STANDALONE = /\A\s*!\[([^\]]*)\]\(\s*([^)\s]+?)\s*(?:"[^"]*")?\s*\)\s*\z/
20
+ ANY_IMAGE = /!\[[^\]]*\]\([^)]*\)/
21
+ EXTERNAL = %r{\Ahttps?://}i
22
+
23
+ Image = Data.define(:index, :alt, :path) do
24
+ def sentinel = format("NOTIONPUBLISHIMAGE%04d", index)
25
+ end
26
+
27
+ attr_reader :images, :inline_paths
28
+
29
+ def self.scan(body, base_dir:) = new(body, base_dir: base_dir)
30
+
31
+ def initialize(body, base_dir:)
32
+ @body = body.to_s
33
+ @base_dir = base_dir
34
+ @images = []
35
+ @inline_paths = []
36
+ @rewritten = rewrite
37
+ end
38
+
39
+ def any? = !images.empty?
40
+
41
+ # The body with each local image replaced by its sentinel, ready to send as
42
+ # the `markdown` parameter.
43
+ def body_with_sentinels = @rewritten
44
+
45
+ def resolved_path(image) = File.expand_path(image.path, @base_dir)
46
+
47
+ private
48
+
49
+ def rewrite
50
+ in_fence = false
51
+ index = 0
52
+
53
+ lines = @body.lines.map do |line|
54
+ if line.match?(FENCE)
55
+ in_fence = !in_fence
56
+ next line
57
+ end
58
+ next line if in_fence
59
+
60
+ match = line.match(STANDALONE)
61
+ if match && local?(match[2])
62
+ image = Image.new(index: index, alt: match[1], path: unescape(match[2]))
63
+ @images << image
64
+ index += 1
65
+ next "#{line[/\A\s*/]}#{image.sentinel}\n"
66
+ end
67
+
68
+ # An image among other text cannot become a block. Note it so the caller
69
+ # can say so rather than letting it publish as an empty URL.
70
+ collect_inline(line) if match.nil? && line.match?(ANY_IMAGE)
71
+ line
72
+ end
73
+
74
+ lines.join
75
+ end
76
+
77
+ def collect_inline(line)
78
+ line.scan(/!\[[^\]]*\]\(\s*([^)\s]+?)\s*(?:"[^"]*")?\s*\)/) do |(url)|
79
+ @inline_paths << url if local?(url)
80
+ end
81
+ end
82
+
83
+ # Anything that is not a well-formed http(s) URL has to be treated as local.
84
+ # A malformed URL produces the same silent empty-URL block that a relative
85
+ # path does, so guessing charitably here would hide the failure.
86
+ def local?(url) = !url.to_s.match?(EXTERNAL)
87
+
88
+ def unescape(url) = url.to_s.gsub("%20", " ").delete_prefix("<").delete_suffix(">")
89
+ end
90
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module NotionPublish
6
+ # The hash of a page as Notion returns it, used to notice edits made in
7
+ # Notion.
8
+ #
9
+ # An uploaded file comes back as a signed S3 URL whose query string is
10
+ # minted fresh on every read, so the raw Markdown of any page with an
11
+ # uploaded image differs from one read to the next. The signature is
12
+ # removed before hashing; the path, which names the file, is kept. Pages
13
+ # without such URLs hash exactly as their raw Markdown does.
14
+ module NotionDigest
15
+ SIGNED_QUERY = %r{(https://[^\s)"'<>]+?)\?[^\s)"'<>]*X-Amz-[^\s)"'<>]*}
16
+
17
+ module_function
18
+
19
+ def of(markdown) = Digest::SHA256.hexdigest(stable(markdown))
20
+
21
+ def stable(markdown) = markdown.to_s.gsub(SIGNED_QUERY, '\1')
22
+ end
23
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NotionPublish
4
+ # Runs work over a list a few items at a time, and hands the results back
5
+ # in the list's order.
6
+ #
7
+ # Checking a page is almost all waiting on Notion, so a few requests in
8
+ # flight cut the time roughly by that factor. Notion allows about three
9
+ # requests a second on average; more workers than that would mostly buy
10
+ # rate-limit retries.
11
+ #
12
+ # Only the work runs on worker threads. The +started+ and +finished+
13
+ # callbacks and the block run on the calling thread, so everything that
14
+ # prints does so from one thread, in order.
15
+ class Pool
16
+ SIZE = 3
17
+
18
+ # Calls +work+ with each item on a worker thread. On the calling thread,
19
+ # calls +started+ with an item when a worker picks it up, +finished+ with
20
+ # the number of items done so far, and yields each item with its result
21
+ # in the original order as soon as every item before it is done. Returns
22
+ # the results in order.
23
+ #
24
+ # An exception raised by +work+ stops the run and is raised here, once
25
+ # the items before it have been yielded.
26
+ def self.run(items, work:, size: SIZE, started: nil, finished: nil, &emit)
27
+ new(items, work: work, started: started, finished: finished, emit: emit).run(size)
28
+ end
29
+
30
+ def initialize(items, work:, started:, finished:, emit:)
31
+ @items = items
32
+ @work = work
33
+ @started = started
34
+ @finished = finished
35
+ @emit = emit
36
+ @results = Array.new(items.length)
37
+ @events = Queue.new
38
+ end
39
+
40
+ def run(size)
41
+ return [] if @items.empty?
42
+
43
+ workers = Array.new([size, @items.length].min) { worker(jobs) }
44
+ collect
45
+ @results.map { |_, value| value }
46
+ ensure
47
+ workers&.each(&:kill)
48
+ end
49
+
50
+ private
51
+
52
+ def jobs
53
+ @jobs ||= Queue.new.tap do |queue|
54
+ @items.each_with_index { |item, index| queue << [item, index] }
55
+ queue.close
56
+ end
57
+ end
58
+
59
+ def worker(queue)
60
+ Thread.new do
61
+ while (job = queue.pop)
62
+ item, index = job
63
+ @events << [:started, index]
64
+ @results[index] = attempt(item)
65
+ @events << [:finished, index]
66
+ end
67
+ end
68
+ end
69
+
70
+ def attempt(item)
71
+ [:ok, @work.call(item)]
72
+ rescue StandardError => e
73
+ [:raised, e]
74
+ end
75
+
76
+ # Until every item has reported finishing, not merely until every result
77
+ # is in: a worker stores its result just before it says so.
78
+ def collect
79
+ done = 0
80
+ emitted = 0
81
+ while done < @items.length
82
+ kind, index = @events.pop
83
+ next @started&.call(@items[index]) if kind == :started
84
+
85
+ done += 1
86
+ @finished&.call(done)
87
+ emitted = emit_ready(emitted)
88
+ end
89
+ end
90
+
91
+ # Yields every finished result that has no unfinished item before it.
92
+ def emit_ready(emitted)
93
+ while emitted < @items.length && @results[emitted]
94
+ status, value = @results[emitted]
95
+ raise value if status == :raised
96
+
97
+ @emit&.call(@items[emitted], value)
98
+ emitted += 1
99
+ end
100
+ emitted
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "delegate"
4
+ require "io/console"
5
+
6
+ module NotionPublish
7
+ # A single progress line on a terminal, such as "Checking 12/38 a.md":
8
+ # how many items are finished, and one that is in progress. It is rewritten
9
+ # in place and cleared when the run ends.
10
+ #
11
+ # It exists only when stderr is a terminal, following git, curl, and rsync,
12
+ # so CI logs and pipes never see it. Output written through #wrap clears the
13
+ # line first and redraws it afterwards, so results and the progress line
14
+ # never share a line. A disabled Progress does nothing and wraps nothing.
15
+ class Progress
16
+ CLEAR = "\r\e[K"
17
+ DEFAULT_WIDTH = 80
18
+
19
+ # An IO that keeps the progress line out of the way of what it writes.
20
+ class Output < SimpleDelegator
21
+ def initialize(io, progress)
22
+ super(io)
23
+ @progress = progress
24
+ end
25
+
26
+ def puts(*) = @progress.around { __getobj__.puts(*) }
27
+ def print(*) = @progress.around { __getobj__.print(*) }
28
+ def write(*) = @progress.around { __getobj__.write(*) }
29
+ end
30
+
31
+ def initialize(io, enabled:)
32
+ @io = io
33
+ @enabled = enabled
34
+ @line = nil
35
+ end
36
+
37
+ def enabled? = @enabled
38
+
39
+ def wrap(io) = enabled? ? Output.new(io, self) : io
40
+
41
+ def start(verb, total)
42
+ @verb = verb
43
+ @total = total
44
+ @done = 0
45
+ @label = ""
46
+ end
47
+
48
+ # Names an item being worked on. With several in flight, the line shows
49
+ # the one picked up most recently.
50
+ def started(label)
51
+ @label = label
52
+ redraw
53
+ end
54
+
55
+ # Counts finished items, so the number only rises and reaches the total
56
+ # exactly when the work is done.
57
+ def finished(done)
58
+ @done = done
59
+ redraw
60
+ end
61
+
62
+ # Clears the line for good.
63
+ def finish
64
+ return unless enabled? && @line
65
+
66
+ @io.write(CLEAR)
67
+ @line = nil
68
+ end
69
+
70
+ def around
71
+ return yield unless enabled? && @line
72
+
73
+ @io.write(CLEAR)
74
+ result = yield
75
+ draw
76
+ result
77
+ end
78
+
79
+ private
80
+
81
+ def redraw
82
+ return unless enabled?
83
+
84
+ @line = fit("#{@verb} #{@done}/#{@total} #{@label}")
85
+ draw
86
+ end
87
+
88
+ def draw
89
+ @io.write("#{CLEAR}#{@line}")
90
+ @io.flush
91
+ end
92
+
93
+ # A line wider than the terminal wraps, and a wrapped line cannot be
94
+ # rewritten in place.
95
+ def fit(text)
96
+ width = (@io.winsize[1] if @io.respond_to?(:winsize)).to_i
97
+ width = DEFAULT_WIDTH unless width > 10
98
+ text.length < width ? text : "#{text[0, width - 4]}..."
99
+ rescue SystemCallError, IOError
100
+ text[0, DEFAULT_WIDTH - 1]
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "json"
5
+ require "time"
6
+
7
+ require_relative "errors"
8
+
9
+ module NotionPublish
10
+ # The requested property values, before the schema has been consulted.
11
+ #
12
+ # Every value is held as an array of strings. Repeating --property with the
13
+ # same name accumulates; whether that is legal depends on the property's type,
14
+ # which Schema decides later.
15
+ class PropertySet
16
+ include Enumerable
17
+
18
+ FRONT_MATTER = "front matter"
19
+
20
+ def self.parse_pair(pair)
21
+ name, separator, value = pair.to_s.partition("=")
22
+ raise Error, pair_message(pair) if separator.empty? || name.strip.empty?
23
+
24
+ [name.strip, value]
25
+ end
26
+
27
+ def self.pair_message(pair)
28
+ <<~MSG.strip
29
+ Cannot read #{pair.to_s.inspect} as a property.
30
+
31
+ Use --property 'Name=Value'. Repeat it to give a multi-select or people
32
+ property more than one value:
33
+
34
+ --property 'Function=Operations' --property 'Function=Legal'
35
+ MSG
36
+ end
37
+
38
+ # Layered lowest to highest: front matter, then --properties-json, then
39
+ # --property. Each layer replaces a name outright rather than adding to it,
40
+ # so overriding one property never drags the old values along.
41
+ def self.build(front_matter: nil, json: nil, pairs: [])
42
+ set = new
43
+ set.merge_hash(front_matter, source: FRONT_MATTER)
44
+ set.merge_hash(parse_json(json), source: "--properties-json") if json
45
+ set.merge_pairs(pairs)
46
+ set
47
+ end
48
+
49
+ def self.parse_json(json)
50
+ parsed = JSON.parse(json.to_s)
51
+ raise Error, "--properties-json must be a JSON object, got #{parsed.class}." unless parsed.is_a?(Hash)
52
+
53
+ parsed
54
+ rescue JSON::ParserError => e
55
+ raise Error, "Could not parse --properties-json: #{e.message}"
56
+ end
57
+
58
+ def initialize
59
+ @values = {}
60
+ @explicit = []
61
+ end
62
+
63
+ def merge_hash(hash, source:)
64
+ return self if hash.nil? || hash.empty?
65
+ raise Error, "#{source} properties must be a mapping." unless hash.is_a?(Hash)
66
+
67
+ hash.each do |name, value|
68
+ @values[name.to_s] = normalise(value)
69
+ @explicit << name.to_s if source != FRONT_MATTER
70
+ end
71
+ self
72
+ end
73
+
74
+ # Properties named on the command line were asked for by this invocation;
75
+ # properties from front matter belong to the document. That matters when a
76
+ # destination cannot hold them: an explicit one is an error, a document one
77
+ # is skipped with a warning.
78
+ def explicit?(name) = @explicit.include?(name.to_s)
79
+
80
+ def merge_pairs(pairs)
81
+ seen = {}
82
+ Array(pairs).each do |pair|
83
+ name, value = self.class.parse_pair(pair)
84
+ # First --property for a name replaces any lower layer; later ones add.
85
+ @values[name] = seen[name] ? @values[name] + [value] : [value]
86
+ seen[name] = true
87
+ @explicit << name
88
+ end
89
+ self
90
+ end
91
+
92
+ def each(&) = @values.each(&)
93
+ def empty? = @values.empty?
94
+ def names = @values.keys
95
+ def [](name) = @values[name]
96
+
97
+ private
98
+
99
+ def normalise(value)
100
+ case value
101
+ when nil then [""]
102
+ when Array then value.map { |v| stringify(v) }
103
+ else [stringify(value)]
104
+ end
105
+ end
106
+
107
+ def stringify(value)
108
+ case value
109
+ when true then "true"
110
+ when false then "false"
111
+ when Date, Time then value.iso8601
112
+ else value.to_s
113
+ end
114
+ end
115
+ end
116
+ end