metaschema 0.2.0 → 0.2.2

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,354 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metaschema
4
+ # Generates human-readable Markdown documentation from a parsed Metaschema document.
5
+ #
6
+ # Usage:
7
+ # ms = Metaschema::Root.from_xml(File.read("metaschema.xml"))
8
+ # markdown = MarkdownDocGenerator.generate(ms)
9
+ # File.write("docs.md", markdown)
10
+ #
11
+ # The generator walks the metaschema definition tree and emits Markdown with:
12
+ # - Schema title and version
13
+ # - Table of contents
14
+ # - Assembly, field, and flag definitions with descriptions
15
+ # - Property tables showing types, constraints, and cardinality
16
+ # - Examples from <example> elements
17
+ class MarkdownDocGenerator
18
+ def self.generate(metaschema)
19
+ new(metaschema).generate
20
+ end
21
+
22
+ def initialize(metaschema)
23
+ @metaschema = metaschema
24
+ @output = []
25
+ end
26
+
27
+ def generate
28
+ header
29
+ table_of_contents
30
+ definitions
31
+ @output.join("\n")
32
+ end
33
+
34
+ private
35
+
36
+ def header
37
+ title = extract_text(@metaschema.schema_name) || "Metaschema"
38
+ version = extract_text(@metaschema.schema_version)
39
+ @output << "# #{title}"
40
+ @output << ""
41
+ @output << "**Version:** #{version}" if version
42
+ @output << "" if version
43
+ end
44
+
45
+ def table_of_contents
46
+ assemblies = @metaschema.define_assembly || []
47
+ fields = @metaschema.define_field || []
48
+ flags = @metaschema.define_flag || []
49
+
50
+ items = assemblies.map do |a|
51
+ "- [#{a.name} (Assembly)](##{anchor(a.name)})"
52
+ end
53
+ fields.each { |f| items << "- [#{f.name} (Field)](##{anchor(f.name)})" }
54
+ flags.each { |f| items << "- [#{f.name} (Flag)](##{anchor(f.name)})" }
55
+
56
+ return if items.empty?
57
+
58
+ @output << "## Table of Contents"
59
+ @output << ""
60
+ items.each { |i| @output << i }
61
+ @output << ""
62
+ end
63
+
64
+ def definitions
65
+ (@metaschema.define_assembly || []).each { |a| assembly_section(a) }
66
+ (@metaschema.define_field || []).each { |f| field_section(f) }
67
+ (@metaschema.define_flag || []).each { |f| flag_section(f) }
68
+ end
69
+
70
+ # ── Assembly ───────────────────────────────────────────────────────
71
+
72
+ def assembly_section(asm)
73
+ @output << "## #{asm.name}"
74
+ @output << ""
75
+ formal_name_and_description(asm)
76
+
77
+ # Flags
78
+ flag_rows = (asm.define_flag || []).map do |f|
79
+ flag_row(f, inline: true)
80
+ end
81
+ (asm.flag || []).each do |f|
82
+ flag_rows << ["`#{f.ref}`", "flag", f.required == "yes" ? "Yes" : "No",
83
+ "-"]
84
+ end
85
+
86
+ # Model children
87
+ model = asm.model
88
+ child_rows = []
89
+ if model
90
+ (model.field || []).each { |fr| child_rows << field_ref_row(fr) }
91
+ (model.assembly || []).each { |ar| child_rows << assembly_ref_row(ar) }
92
+ (model.define_field || []).each do |fd|
93
+ child_rows << inline_field_row(fd)
94
+ end
95
+ (model.define_assembly || []).each do |ad|
96
+ child_rows << inline_assembly_row(ad)
97
+ end
98
+ (model.choice || []).each do |c|
99
+ (c.field || []).each do |fr|
100
+ child_rows << field_ref_row(fr, choice: true)
101
+ end
102
+ (c.assembly || []).each do |ar|
103
+ child_rows << assembly_ref_row(ar, choice: true)
104
+ end
105
+ (c.define_field || []).each do |fd|
106
+ child_rows << inline_field_row(fd, choice: true)
107
+ end
108
+ (c.define_assembly || []).each do |ad|
109
+ child_rows << inline_assembly_row(ad, choice: true)
110
+ end
111
+ end
112
+ (model.choice_group || []).each do |cg|
113
+ child_rows << choice_group_row(cg)
114
+ end
115
+ end
116
+
117
+ unless flag_rows.empty? && child_rows.empty?
118
+ @output << "### Properties"
119
+ @output << ""
120
+ @output << "| Name | Type | Required | Description |"
121
+ @output << "|------|------|----------|-------------|"
122
+ flag_rows.each { |r| @output << "| #{r.join(' | ')} |" }
123
+ child_rows.each { |r| @output << "| #{r.join(' | ')} |" }
124
+ @output << ""
125
+ end
126
+
127
+ constraints_section(asm.constraint)
128
+ examples_section(asm.example)
129
+
130
+ @output << "---"
131
+ @output << ""
132
+ end
133
+
134
+ # ── Field ──────────────────────────────────────────────────────────
135
+
136
+ def field_section(fd)
137
+ @output << "## #{fd.name}"
138
+ @output << ""
139
+ formal_name_and_description(fd)
140
+
141
+ @output << "- **Type:** `#{fd.as_type || 'string'}`"
142
+ @output << "- **Collapsible:** #{fd.collapsible == 'yes' ? 'Yes' : 'No'}" if fd.collapsible == "yes"
143
+
144
+ # Flags on this field
145
+ flag_rows = (fd.define_flag || []).map { |f| flag_row(f, inline: true) }
146
+ (fd.flag || []).each do |f|
147
+ flag_rows << ["`#{f.ref}`", "flag", f.required == "yes" ? "Yes" : "No",
148
+ "-"]
149
+ end
150
+
151
+ if flag_rows.any?
152
+ @output << ""
153
+ @output << "### Flags"
154
+ @output << ""
155
+ @output << "| Name | Type | Required | Description |"
156
+ @output << "|------|------|----------|-------------|"
157
+ flag_rows.each { |r| @output << "| #{r.join(' | ')} |" }
158
+ end
159
+
160
+ @output << ""
161
+ constraints_section(fd.constraint)
162
+ examples_section(fd.example)
163
+
164
+ @output << "---"
165
+ @output << ""
166
+ end
167
+
168
+ # ── Flag ───────────────────────────────────────────────────────────
169
+
170
+ def flag_section(fl)
171
+ @output << "## #{fl.name}"
172
+ @output << ""
173
+ formal_name_and_description(fl)
174
+
175
+ @output << "- **Type:** `#{fl.as_type || 'string'}`"
176
+ @output << ""
177
+
178
+ constraints_section(fl.constraint)
179
+ @output << "---"
180
+ @output << ""
181
+ end
182
+
183
+ # ── Constraint helpers ─────────────────────────────────────────────
184
+
185
+ def constraints_section(constraint)
186
+ return unless constraint
187
+
188
+ allowed = constraint.allowed_values
189
+ matches = constraint.matches
190
+
191
+ parts = []
192
+
193
+ if allowed
194
+ Array(allowed).each do |av|
195
+ target = av.respond_to?(:target) ? (av.target || ".") : "."
196
+ values = Array(av.enum).filter_map(&:value)
197
+ allow_other = av.allow_other == "yes"
198
+ level = av.level || "ERROR"
199
+ next if values.empty?
200
+
201
+ desc = "Allowed values for `#{target}`: #{values.map do |v|
202
+ "`#{v}`"
203
+ end.join(', ')}"
204
+ desc += " (or other)" if allow_other
205
+ desc += " [#{level}]"
206
+ parts << desc
207
+ end
208
+ end
209
+
210
+ if matches
211
+ Array(matches).each do |m|
212
+ target = m.target || "."
213
+ if m.regex
214
+ parts << "Matches regex `/#{m.regex}/` on `#{target}`"
215
+ elsif m.datatype
216
+ parts << "Matches datatype `#{m.datatype}` on `#{target}`"
217
+ end
218
+ end
219
+ end
220
+
221
+ return if parts.empty?
222
+
223
+ @output << "### Constraints"
224
+ @output << ""
225
+ parts.each { |p| @output << "- #{p}" }
226
+ @output << ""
227
+ end
228
+
229
+ # ── Examples ───────────────────────────────────────────────────────
230
+
231
+ def examples_section(examples)
232
+ return unless examples && !examples.empty?
233
+
234
+ @output << "### Examples"
235
+ @output << ""
236
+
237
+ Array(examples).each_with_index do |ex, i|
238
+ name = ex.description&.content || "Example #{i + 1}"
239
+ @output << "#### #{name}"
240
+ @output << ""
241
+ if ex.remarks&.content
242
+ @output << ex.remarks.content
243
+ @output << ""
244
+ end
245
+ end
246
+ end
247
+
248
+ # ── Row builders ───────────────────────────────────────────────────
249
+
250
+ def field_ref_row(fr, choice: false)
251
+ ref = fr.ref
252
+ group_as = fr.group_as
253
+ json_name = group_as&.name || fr.use_name&.content || ref
254
+ cardinality = cardinality_str(fr.min_occurs, fr.max_occurs, group_as)
255
+ prefix = choice ? "*choice* " : ""
256
+ ["`#{json_name}`", "#{prefix}field `#{ref}`", cardinality, ""]
257
+ end
258
+
259
+ def assembly_ref_row(ar, choice: false)
260
+ ref = ar.ref
261
+ group_as = ar.group_as
262
+ json_name = group_as&.name || ref
263
+ cardinality = cardinality_str(ar.min_occurs, ar.max_occurs, group_as)
264
+ prefix = choice ? "*choice* " : ""
265
+ ["`#{json_name}`", "#{prefix}assembly `#{ref}`", cardinality, ""]
266
+ end
267
+
268
+ def inline_field_row(fd, choice: false)
269
+ return [] unless fd.name
270
+
271
+ prefix = choice ? "*choice* " : ""
272
+ ["`#{fd.name}`", "#{prefix}field (inline)", "-", ""]
273
+ end
274
+
275
+ def inline_assembly_row(ad, choice: false)
276
+ return [] unless ad.name
277
+
278
+ prefix = choice ? "*choice* " : ""
279
+ ["`#{ad.name}`", "#{prefix}assembly (inline)", "-", ""]
280
+ end
281
+
282
+ def choice_group_row(cg)
283
+ group_as = cg.group_as
284
+ json_name = group_as&.name || "choice-group"
285
+ ["`#{json_name}`", "choice group", cardinality_str(nil, nil, group_as),
286
+ ""]
287
+ end
288
+
289
+ def flag_row(fl, inline: false)
290
+ name = fl.name
291
+ type = fl.as_type || "string"
292
+ desc = extract_description(fl)
293
+ ["`#{name}`", "flag `#{type}`", fl.required == "yes" ? "Yes" : "No", desc]
294
+ end
295
+
296
+ # ── Helpers ────────────────────────────────────────────────────────
297
+
298
+ def formal_name_and_description(defn)
299
+ formal = defn.formal_name
300
+ if formal && !formal.is_a?(TrueClass)
301
+ text = formal.is_a?(String) ? formal : formal.content
302
+ @output << "**#{text}**" if text && !text.empty?
303
+ @output << ""
304
+ end
305
+
306
+ desc = extract_description(defn)
307
+ if desc && !desc.empty?
308
+ @output << desc
309
+ @output << ""
310
+ end
311
+ end
312
+
313
+ def extract_description(defn)
314
+ return nil unless defn.respond_to?(:description) && defn.description
315
+
316
+ if defn.description.respond_to?(:content)
317
+ defn.description.content
318
+ else
319
+ defn.description.to_s
320
+ end
321
+ end
322
+
323
+ def extract_text(value)
324
+ return nil unless value
325
+
326
+ if value.respond_to?(:content)
327
+ value.content
328
+ elsif value.is_a?(String)
329
+ value
330
+ else
331
+ value.to_s
332
+ end
333
+ end
334
+
335
+ def cardinality_str(min, max, group_as)
336
+ min_val = min.to_i
337
+ max_val = max == "unbounded" ? nil : max&.to_i
338
+
339
+ if group_as
340
+ "1..#{max_val || '*'}" if min_val >= 0
341
+ elsif min_val.positive? && max_val
342
+ "#{min_val}..#{max_val}"
343
+ elsif min_val.positive?
344
+ "#{min_val}..*"
345
+ else
346
+ "0..1"
347
+ end
348
+ end
349
+
350
+ def anchor(name)
351
+ name.downcase.gsub(/[^a-z0-9-]/, "-")
352
+ end
353
+ end
354
+ end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Metaschema
4
4
  class MarkupLineDatatype < Lutaml::Model::Serializable
5
- attribute :content, :string
5
+ attribute :content, :string, collection: true
6
6
  attribute :a, AnchorType, collection: true
7
7
  attribute :insert, InsertType, collection: true
8
8
  attribute :br, :string, collection: true
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metaschema
4
+ class MarkupMultilineDatatype < Lutaml::Model::Serializable
5
+ attribute :h1, InlineMarkupType, collection: true
6
+ attribute :h2, InlineMarkupType, collection: true
7
+ attribute :h3, InlineMarkupType, collection: true
8
+ attribute :h4, InlineMarkupType, collection: true
9
+ attribute :h5, InlineMarkupType, collection: true
10
+ attribute :h6, InlineMarkupType, collection: true
11
+ attribute :ul, ListType, collection: true
12
+ attribute :ol, OrderedListType, collection: true
13
+ attribute :pre, PreformattedType, collection: true
14
+ attribute :hr, :string, collection: true
15
+ attribute :blockquote, BlockQuoteType, collection: true
16
+ attribute :p, InlineMarkupType, collection: true
17
+ attribute :table, TableType, collection: true
18
+ attribute :img, ImageType, collection: true
19
+
20
+ xml do
21
+ element "MarkupMultilineDatatype"
22
+ namespace ::Metaschema::Namespace
23
+ mixed_content
24
+
25
+ map_element "h1", to: :h1
26
+ map_element "h2", to: :h2
27
+ map_element "h3", to: :h3
28
+ map_element "h4", to: :h4
29
+ map_element "h5", to: :h5
30
+ map_element "h6", to: :h6
31
+ map_element "ul", to: :ul
32
+ map_element "ol", to: :ol
33
+ map_element "pre", to: :pre
34
+ map_element "hr", to: :hr
35
+ map_element "blockquote", to: :blockquote
36
+ map_element "p", to: :p
37
+ map_element "table", to: :table
38
+ map_element "img", to: :img
39
+ end
40
+ end
41
+ end