gloo-md 1.4 → 1.5

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 (5) hide show
  1. checksums.yaml +4 -4
  2. data/lib/markdown_ext.rb +19 -8
  3. data/lib/md.rb +15 -7
  4. data/lib/md_doc.rb +150 -34
  5. metadata +69 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0fabc46f939b1be7b157b2ac465d414d49229dcdfa9a488d975027e72a1b0c77
4
- data.tar.gz: eaee04c792674ccdc41fa779940ba8da31e4943f885238521591ccfef1d33775
3
+ metadata.gz: a096b22407ded192c923fe7a248739abdf91312dfb7c3adbb07971b8d521a8ab
4
+ data.tar.gz: c9e5db96603db5998f7f5d99c028760ea5d457e8efd531c5cea126491595a3e8
5
5
  SHA512:
6
- metadata.gz: 4aebf3742809ae3b86e3e17ffbfc308b847083a76a1cda6bac82e2378b01853ac87f4bfec7390b95565e48d95794fad769d6194c08399ec6c52afbdc66d41df0
7
- data.tar.gz: 36b30547c2ab17834f42e34b1376248244d2cd1a6bcbc984290f01ee499dea04beac810ac8891f34631a0dba5360546e775703c78070e99d27bd4c36fb587345
6
+ metadata.gz: 95def51afd4d575cb46bca609e233f8916c94cd40f4811751638b37b6661c9f2c8d84a902c76b64369259d477a57ee7683c0f5f34cbb33f2e8c4cbd03c660b71
7
+ data.tar.gz: c65726498e76d43dac867a9da4f6f47290e819d873be64eee01b2481d5519439c406d709a43595cc17f4e2c97c03133c8e972b337becfc60563a8f486a569f73
data/lib/markdown_ext.rb CHANGED
@@ -26,20 +26,27 @@ class MarkdownExt
26
26
 
27
27
  #
28
28
  # Render gloo markdown extensions.
29
+ # A block runs from its [!...] line to the next blank line, the next
30
+ # block, or the end of the data. The engine, when given, is used to
31
+ # warn about an unknown extension.
29
32
  #
30
- def self.render_extensions data
33
+ def self.render_extensions( data, engine = nil )
31
34
  return '' if data.nil?
32
35
 
33
36
  out_data = ""
34
37
  one_ext = ""
35
38
  in_ext = false
36
39
  data.lines.each_with_index do |line, index|
37
- if line.start_with?( '[!' )
40
+ # A line starting [![ is a Markdown image link (eg. a badge), not a block.
41
+ if line.start_with?( '[!' ) && !line.start_with?( '[![' )
42
+ # A new block finishes any block still open; a blank line keeps
43
+ # the two apart for the Markdown renderer.
44
+ out_data << render_one_ext( one_ext, engine ) << "\n" if in_ext
38
45
  in_ext = true
39
- one_ext = line
46
+ one_ext = line.dup
40
47
  elsif in_ext && line.strip.blank?
41
48
  in_ext = false
42
- out_data << render_one_ext( one_ext )
49
+ out_data << render_one_ext( one_ext, engine )
43
50
  out_data << line
44
51
  elsif in_ext
45
52
  one_ext << line
@@ -48,6 +55,9 @@ class MarkdownExt
48
55
  end
49
56
  end
50
57
 
58
+ # A block at the very end has no blank line after it.
59
+ out_data << render_one_ext( one_ext, engine ) if in_ext
60
+
51
61
  return out_data
52
62
  end
53
63
 
@@ -58,7 +68,7 @@ class MarkdownExt
58
68
  #
59
69
  # Render one markdown extension.
60
70
  #
61
- def self.render_one_ext( data )
71
+ def self.render_one_ext( data, engine = nil )
62
72
  if data.start_with?( PANEL )
63
73
  return render_panel( data )
64
74
  elsif data.start_with?( QUOTE )
@@ -72,9 +82,10 @@ class MarkdownExt
72
82
  elsif data.start_with?( IDEA )
73
83
  return render_idea( data )
74
84
  else
75
- # ERROR
76
- puts "ERROR: unknown markdown extension: #{data}"
77
- return ""
85
+ # Keep the block as plain text so its content isn't lost.
86
+ tag = data.lines.first.to_s[ /\A\[![^\]]*\]?/ ]
87
+ engine&.warn "Unknown markdown extension '#{tag}'; it was shown as plain text."
88
+ return data
78
89
  end
79
90
  end
80
91
 
data/lib/md.rb CHANGED
@@ -67,21 +67,26 @@ class Md < Gloo::Core::Obj
67
67
 
68
68
  #
69
69
  # Render the markdown as HTML.
70
- # Needs an optional parameter of where to put the rendered html.
71
- # The html will be in 'it' as well.
70
+ # With an optional destination, the HTML goes there and it is true
71
+ # (false if the destination doesn't exist). With none, the HTML is
72
+ # put in it.
72
73
  #
73
74
  def msg_render
74
- html = MarkdownExt.render_extensions( value )
75
+ html = MarkdownExt.render_extensions( value, @engine )
75
76
  html = Md.md_2_html( html )
76
77
 
77
- # Put the HTML in the optional parameter if one is given.
78
78
  if @params&.token_count&.positive?
79
79
  pn = Gloo::Core::Pn.new( @engine, @params.first )
80
80
  o = pn.resolve
81
+ unless o
82
+ @engine.err Gloo::Core::NotFound.object( @params.first )
83
+ return @engine.heap.it.set_to( false )
84
+ end
85
+
81
86
  o.set_value html
87
+ return @engine.heap.it.set_to( true )
82
88
  end
83
89
 
84
- # Put the HTML in it, in any case.
85
90
  @engine.heap.it.set_to html
86
91
  end
87
92
 
@@ -140,10 +145,13 @@ class Md < Gloo::Core::Obj
140
145
  :shortcut => KEYWORD_SHORT,
141
146
  :description => 'Markdown data in a text string. Also supports ' \
142
147
  'gloo Markdown extensions (panel, note, quote, idea and check ' \
143
- 'blocks) rendered via MarkdownExt when the data is rendered.',
148
+ 'blocks) rendered via MarkdownExt when the data is rendered. ' \
149
+ 'A block runs from its [!...] line to the next blank line, the ' \
150
+ 'next block, or the end of the text. An unknown extension is ' \
151
+ 'kept as plain text, with a warning.',
144
152
  :messages => [
145
153
  'show — Show the markdown data in the terminal.',
146
- 'render ({dst}) — Convert the markdown to HTML and put it in the {dst} object. The HTML is also put into it either way.',
154
+ 'render ({dst}) — Convert the markdown to HTML. With {dst}, the HTML is put in the {dst} object and it is true (a {dst} that does not exist is an error, and it is false). With no {dst}, the HTML is put in it.',
147
155
  'update_asset_path — Update asset paths for all images in the source markdown, so files can refer to images from a path different from the page using them.'
148
156
  ],
149
157
  :examples => <<~EXAMPLES.strip
data/lib/md_doc.rb CHANGED
@@ -6,6 +6,7 @@
6
6
  # children and the Markdown body as a text child.
7
7
  #
8
8
  require 'yaml'
9
+ require 'date'
9
10
 
10
11
  class MdDoc < Gloo::Core::Obj
11
12
 
@@ -79,66 +80,75 @@ class MdDoc < Gloo::Core::Obj
79
80
 
80
81
  #
81
82
  # Read the file at path, parse frontmatter and body, populate children.
82
- # Only scalar frontmatter values become gloo string children; complex values
83
- # (arrays, nested hashes) are skipped — they are preserved on write by
84
- # re-reading the file.
83
+ # Only scalar frontmatter values (including dates and times) become gloo
84
+ # string children; complex values (arrays, nested hashes) are skipped —
85
+ # they are preserved on write by re-reading the file.
86
+ # It is true when the file was read, false if it couldn't be.
85
87
  #
86
88
  def msg_read
87
89
  path = resolve_path
88
- return unless path
90
+ return @engine.heap.it.set_to( false ) unless path
89
91
 
90
- unless File.exist?( path )
91
- @engine.log.error "md_doc file not found: #{path}"
92
- return
93
- end
92
+ content = read_file( path )
93
+ return @engine.heap.it.set_to( false ) unless content
94
94
 
95
- content = File.read( path )
96
- fm_hash, body_text = parse_frontmatter( content )
95
+ fm_hash, body_text = parse_frontmatter( content, path )
96
+ return @engine.heap.it.set_to( false ) unless fm_hash
97
97
 
98
98
  fm_can = find_child FRONTMATTER
99
- if fm_can && fm_hash
99
+ # Start from the file's keys only, not ones left from an earlier read.
100
+ fm_can&.delete_children
101
+ if fm_can
100
102
  fm_hash.each do |key, val|
101
103
  next unless scalar?( val )
102
104
  child = fm_can.find_add_child( key.to_s, 'string' )
103
- child.set_value val.to_s
105
+ child.set_value scalar_text( val )
104
106
  end
105
107
  end
106
108
 
107
109
  body = find_child BODY
108
110
  body.set_value( body_text ) if body
111
+ @engine.heap.it.set_to true
109
112
  end
110
113
 
111
114
  #
112
115
  # Serialize frontmatter and body children back to the file at path.
113
116
  # Re-reads the current file to get the base hash (preserving arrays and other
114
- # complex values), then overlays the scalar children which may have been
115
- # modified. Creates the file if it does not exist.
117
+ # complex values), then overlays the scalar children that were changed.
118
+ # Creates the file if it does not exist.
116
119
  #
117
120
  def msg_write
118
121
  path = resolve_path
119
122
  return unless path
120
123
 
121
- expanded = File.expand_path( path )
122
-
123
124
  # Re-read the file so arrays and nested hashes survive unchanged.
124
125
  base = {}
125
- if File.exist?( expanded )
126
- base, _ = parse_frontmatter( File.read( expanded ) )
126
+ if File.exist?( path )
127
+ content = read_file( path )
128
+ return unless content
129
+
130
+ base, _ = parse_frontmatter( content, path )
131
+ # Invalid frontmatter was reported; don't overwrite the file.
132
+ return unless base
127
133
  end
128
134
 
129
135
  fm_can = find_child FRONTMATTER
130
136
 
131
- # Overlay scalar children; updating an existing key preserves its position.
137
+ # Overlay changed children; updating an existing key preserves its
138
+ # position, and an unchanged one keeps its YAML type (eg. a date).
139
+ # A changed one keeps its type too when its new text fits it.
132
140
  if fm_can
133
141
  fm_can.children.each do |child|
134
- base[ child.name ] = child.value
142
+ next if base.key?( child.name ) && scalar_text( base[ child.name ] ) == child.value.to_s
143
+
144
+ base[ child.name ] = typed_value( base[ child.name ], child.value )
135
145
  end
136
146
  end
137
147
 
138
148
  body = find_child BODY
139
149
  body_text = body ? body.value.to_s : ''
140
150
 
141
- File.write( expanded, build_content( base, body_text ) )
151
+ file_op( 'write', path ) { File.write( path, build_content( base, body_text ) ) }
142
152
  end
143
153
 
144
154
 
@@ -154,33 +164,129 @@ class MdDoc < Gloo::Core::Obj
154
164
  #
155
165
  def scalar?( val )
156
166
  val.is_a?( String ) || val.is_a?( Integer ) || val.is_a?( Float ) ||
157
- val.is_a?( TrueClass ) || val.is_a?( FalseClass ) || val.nil?
167
+ val.is_a?( TrueClass ) || val.is_a?( FalseClass ) || val.nil? ||
168
+ val.is_a?( Date ) || val.is_a?( Time )
169
+ end
170
+
171
+ #
172
+ # The text for a scalar frontmatter value, as its string child holds it.
173
+ # YAML reads a time with no zone as UTC, so times are shown in UTC
174
+ # (eg. 2026-10-01 09:30:00 UTC) rather than converted to local time.
175
+ #
176
+ def scalar_text( val )
177
+ return val.utc.strftime( '%Y-%m-%d %H:%M:%S UTC' ) if val.is_a?( Time )
178
+
179
+ return val.to_s
180
+ end
181
+
182
+ #
183
+ # The kind of a frontmatter value whose type write keeps: :boolean,
184
+ # :number, :date or :time. Nil for text and anything else.
185
+ #
186
+ def value_kind( val )
187
+ case val
188
+ when TrueClass, FalseClass then :boolean
189
+ when Integer, Float then :number
190
+ when Time then :time
191
+ when Date then :date
192
+ end
193
+ end
194
+
195
+ #
196
+ # The value to write for a changed child. If the old value was a
197
+ # boolean, number, date or time, and the new text reads as the same
198
+ # kind of value (and back as the same text), write it as that kind;
199
+ # otherwise write the text as a string.
200
+ #
201
+ def typed_value( old, text )
202
+ kind = value_kind( old )
203
+ return text unless kind
204
+
205
+ # Read shows times with a UTC suffix, which YAML spells Z.
206
+ parsed = YAML.safe_load( text.to_s.sub( / UTC\z/, ' Z' ), permitted_classes: [ Date, Time ] )
207
+ return text unless value_kind( parsed ) == kind
208
+ # Text that YAML would trim or reinterpret (eg. '3 # note') stays text.
209
+ return text unless without_zone( scalar_text( parsed ) ) == without_zone( text.to_s )
210
+
211
+ return parsed
212
+ rescue Psych::Exception
213
+ return text
214
+ end
215
+
216
+ #
217
+ # A time's text without a trailing UTC or Z zone, for comparing.
218
+ #
219
+ def without_zone( text )
220
+ return text.sub( / (UTC|Z)\z/, '' )
158
221
  end
159
222
 
160
223
  #
161
224
  # Get the expanded path string from the path child.
225
+ # Reports an error and returns nil if there's no path.
162
226
  #
163
227
  def resolve_path
164
228
  o = find_child PATH
165
- return nil unless o
166
- return nil if o.value.to_s.strip.empty?
229
+ if o.nil? || o.value.to_s.strip.empty?
230
+ @engine.err "md_doc '#{name}' has no path; put a file path into #{name}.path first."
231
+ return nil
232
+ end
167
233
 
168
234
  File.expand_path( o.value.to_s )
169
235
  end
170
236
 
237
+ #
238
+ # Read the file at path. Reports an error and returns nil if it's
239
+ # missing or can't be read.
240
+ #
241
+ def read_file( path )
242
+ unless File.exist?( path )
243
+ @engine.err Gloo::Core::NotFound.file( path )
244
+ return nil
245
+ end
246
+
247
+ return file_op( 'read', path ) { File.read( path ) }
248
+ end
249
+
250
+ #
251
+ # Run a file operation, reporting a failure (eg. no permission, a
252
+ # missing folder) as an error. Returns nil if it failed.
253
+ #
254
+ def file_op( action, path )
255
+ return yield
256
+ rescue SystemCallError => e
257
+ @engine.err "Could not #{action} '#{path}': #{e.message}"
258
+ return nil
259
+ end
260
+
171
261
  #
172
262
  # Parse YAML frontmatter from file content.
173
263
  # Returns [fm_hash, body_text]. If there is no frontmatter block,
174
264
  # fm_hash is empty and body_text is the full content.
265
+ # Dates and times are allowed. Invalid YAML is reported as an error,
266
+ # and fm_hash is nil.
175
267
  #
176
- def parse_frontmatter( content )
268
+ def parse_frontmatter( content, path )
177
269
  if content =~ /\A---\s*\n(.*?\n)---\s*\n?(.*)\z/m
178
- fm_hash = YAML.safe_load( $1 ) || {}
179
- body_text = $2
180
- return fm_hash, body_text
270
+ fm_text, body_text = $1, $2
271
+ fm_hash = YAML.safe_load( fm_text, permitted_classes: [ Date, Time ] ) || {}
272
+ return fm_hash, body_text if fm_hash.is_a?( Hash )
273
+
274
+ return frontmatter_err( path, "it isn't a list of key: value pairs" )
181
275
  end
182
276
 
183
277
  return {}, content
278
+ rescue Psych::SyntaxError => e
279
+ return frontmatter_err( path, "it isn't valid YAML (#{e.problem})" )
280
+ rescue Psych::Exception => e
281
+ return frontmatter_err( path, "it couldn't be loaded (#{e.message})" )
282
+ end
283
+
284
+ #
285
+ # Report frontmatter that can't be used, and return no hash or body.
286
+ #
287
+ def frontmatter_err( path, reason )
288
+ @engine.err "Couldn't read the frontmatter in '#{path}': #{reason}."
289
+ return nil, nil
184
290
  end
185
291
 
186
292
  #
@@ -192,8 +298,15 @@ class MdDoc < Gloo::Core::Obj
192
298
  return body_text
193
299
  end
194
300
 
301
+ # Write times in UTC, as they were most likely written, not local time.
302
+ fm_hash = fm_hash.transform_values { |v| v.is_a?( Time ) ? v.utc : v }
303
+
195
304
  # to_yaml emits "---\nkey: val\n"; strip the leading "---\n" and rewrap.
196
305
  fm_yaml = fm_hash.to_yaml.sub( /\A---\n/, '' )
306
+
307
+ # to_yaml writes a UTC time as 2026-10-01 09:30:00.000000000 Z. A time
308
+ # with no zone reads as UTC, so dropping the suffix keeps the same time.
309
+ fm_yaml = fm_yaml.gsub( /(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d)\.0+ Z$/, '\1' )
197
310
  "---\n#{fm_yaml}---\n#{body_text}"
198
311
  end
199
312
 
@@ -220,15 +333,18 @@ class MdDoc < Gloo::Core::Obj
220
333
  'body (text) — The Markdown body (everything after the --- closing delimiter).'
221
334
  ],
222
335
  :messages => [
223
- 'read — Read the file at path, parse the YAML frontmatter and Markdown body. Frontmatter children are created dynamically from whatever keys are present in the file. Populates frontmatter.* children and body.',
224
- 'write — Serialize frontmatter children back to YAML and combine with body. Writes the result to the file at path, creating it if it does not exist. Key order is preserved; quoting style may normalize on first write, but semantic content is unchanged.'
336
+ 'read — Read the file at path, parse the YAML frontmatter and Markdown body. The frontmatter children are replaced with exactly the keys in the file (keys from an earlier read are removed); dates and times become text (times in UTC). Populates frontmatter.* children and body. It is true when the file was read, false if it could not be.',
337
+ 'write — Serialize frontmatter children back to YAML and combine with body. Writes the result to the file at path, creating it if it does not exist. Key order is preserved. A value keeps its YAML type (a date, time, number or true/false): unchanged values always, and changed values when the new text is that type too (eg. 2026-10-05 for a date); otherwise the new text is written as text.'
225
338
  ],
226
339
  :notes => 'If the file has no frontmatter block, frontmatter ' \
227
340
  'will have no children and body will contain the full file ' \
228
- 'content. If path is empty or the file does not exist, read ' \
229
- 'logs an error and returns without modifying children. Uses ' \
230
- "Ruby's built-in psych library for YAML parsing — no " \
231
- 'additional dependencies.',
341
+ 'content. These are errors, and read leaves the children as ' \
342
+ 'they were and puts false in it: an empty path, a file that ' \
343
+ 'does not exist or cannot be read, and frontmatter that is not ' \
344
+ 'valid YAML. An empty path or a failed write is also an error ' \
345
+ 'for write; write does not overwrite a file whose frontmatter ' \
346
+ "is not valid YAML. Uses Ruby's built-in psych library for " \
347
+ 'YAML parsing — no additional dependencies.',
232
348
  :examples => <<~EXAMPLES.strip
233
349
  doc [md_doc] :
234
350
  path [file] : ~/notes/project.md
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gloo-md
3
3
  version: !ruby/object:Gem::Version
4
- version: '1.4'
4
+ version: '1.5'
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eric Crane
@@ -23,6 +23,74 @@ dependencies:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
25
  version: 3.6.0
26
+ - !ruby/object:Gem::Dependency
27
+ name: gloo
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '7.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '7.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: bundler
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: minitest
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '5.1'
61
+ - - ">="
62
+ - !ruby/object:Gem::Version
63
+ version: 5.14.2
64
+ type: :development
65
+ prerelease: false
66
+ version_requirements: !ruby/object:Gem::Requirement
67
+ requirements:
68
+ - - "~>"
69
+ - !ruby/object:Gem::Version
70
+ version: '5.1'
71
+ - - ">="
72
+ - !ruby/object:Gem::Version
73
+ version: 5.14.2
74
+ - !ruby/object:Gem::Dependency
75
+ name: rake
76
+ requirement: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - "~>"
79
+ - !ruby/object:Gem::Version
80
+ version: '13.0'
81
+ - - ">="
82
+ - !ruby/object:Gem::Version
83
+ version: 13.0.1
84
+ type: :development
85
+ prerelease: false
86
+ version_requirements: !ruby/object:Gem::Requirement
87
+ requirements:
88
+ - - "~>"
89
+ - !ruby/object:Gem::Version
90
+ version: '13.0'
91
+ - - ">="
92
+ - !ruby/object:Gem::Version
93
+ version: 13.0.1
26
94
  description: Adds Markdown support to Gloo.
27
95
  email:
28
96
  - eric.crane@mac.com