andromeda_cms 0.1.0-aarch64-linux-musl

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 (53) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +30 -0
  3. data/CONTRIBUTING.md +47 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +276 -0
  6. data/lib/andromeda/assets.rb +128 -0
  7. data/lib/andromeda/check.rb +71 -0
  8. data/lib/andromeda/components/errors.rb +97 -0
  9. data/lib/andromeda/components/import_scanner.rb +72 -0
  10. data/lib/andromeda/components/static_expression.rb +79 -0
  11. data/lib/andromeda/components.rb +348 -0
  12. data/lib/andromeda/configuration.rb +91 -0
  13. data/lib/andromeda/entry.rb +294 -0
  14. data/lib/andromeda/errors.rb +173 -0
  15. data/lib/andromeda/fix.rb +71 -0
  16. data/lib/andromeda/frontmatter.rb +327 -0
  17. data/lib/andromeda/helpers.rb +84 -0
  18. data/lib/andromeda/id.rb +58 -0
  19. data/lib/andromeda/loader.rb +96 -0
  20. data/lib/andromeda/parser.rb +75 -0
  21. data/lib/andromeda/pipeline.rb +296 -0
  22. data/lib/andromeda/railtie.rb +47 -0
  23. data/lib/andromeda/registry.rb +127 -0
  24. data/lib/andromeda/relation.rb +125 -0
  25. data/lib/andromeda/renderer/code_highlighter.rb +53 -0
  26. data/lib/andromeda/renderer/errors.rb +52 -0
  27. data/lib/andromeda/renderer/literal_expression.rb +201 -0
  28. data/lib/andromeda/renderer.rb +411 -0
  29. data/lib/andromeda/schema.rb +383 -0
  30. data/lib/andromeda/slugger.rb +68 -0
  31. data/lib/andromeda/store.rb +286 -0
  32. data/lib/andromeda/tasks/andromeda.rake +56 -0
  33. data/lib/andromeda/version.rb +5 -0
  34. data/lib/andromeda_cms/3.2/andromeda_cms.so +0 -0
  35. data/lib/andromeda_cms/3.3/andromeda_cms.so +0 -0
  36. data/lib/andromeda_cms/3.4/andromeda_cms.so +0 -0
  37. data/lib/andromeda_cms/4.0/andromeda_cms.so +0 -0
  38. data/lib/andromeda_cms.rb +41 -0
  39. data/lib/generators/andromeda/collection/USAGE +25 -0
  40. data/lib/generators/andromeda/collection/collection_generator.rb +239 -0
  41. data/lib/generators/andromeda/component/USAGE +15 -0
  42. data/lib/generators/andromeda/component/component_generator.rb +75 -0
  43. data/lib/generators/andromeda/import_astro/USAGE +21 -0
  44. data/lib/generators/andromeda/import_astro/astro_schema_json.rb +80 -0
  45. data/lib/generators/andromeda/import_astro/balanced_scanner.rb +168 -0
  46. data/lib/generators/andromeda/import_astro/content_config_converter.rb +232 -0
  47. data/lib/generators/andromeda/import_astro/import_astro_generator.rb +313 -0
  48. data/lib/generators/andromeda/import_astro/mdx_content_scanner.rb +91 -0
  49. data/lib/generators/andromeda/import_astro/mdx_import_rewriter.rb +91 -0
  50. data/lib/generators/andromeda/install/USAGE +11 -0
  51. data/lib/generators/andromeda/install/install_generator.rb +77 -0
  52. data/lib/generators/andromeda/install/templates/initializer.rb +30 -0
  53. metadata +163 -0
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "andromeda_cms"
4
+ require "active_support/core_ext/string/inflections"
5
+
6
+ module Andromeda
7
+ module Generators
8
+ # `rails g andromeda:component NAME [prop ...]` -- a starter
9
+ # partial for an MDX component, named by convention
10
+ # (`Callout` -> `_callout.html.erb`) so no registration step is
11
+ # needed for the common case.
12
+ class ComponentGenerator < Rails::Generators::NamedBase
13
+ argument :props, type: :array, default: [], banner: "prop prop"
14
+
15
+ # An MDX tag is a single JSX identifier (`<Callout>`), never a
16
+ # namespaced or dotted one -- reject anything NamedBase's own parsing
17
+ # would otherwise silently accept (e.g. it treats `foo/bar` as a
18
+ # namespace and `foo.bar` as one odd file name).
19
+ VALID_NAME_RE = /\A[A-Za-z][A-Za-z0-9_]*\z/
20
+
21
+ def validate_component_name!
22
+ return if name.match?(VALID_NAME_RE)
23
+
24
+ raise ArgumentError,
25
+ "invalid component name #{name.inspect} -- use a single CamelCase or underscored word " \
26
+ "(e.g. `Callout` or `callout`); it becomes " \
27
+ "#{Andromeda.config.components_path}/_#{name.underscore}.html.erb"
28
+ end
29
+
30
+ def create_partial
31
+ create_file "#{Andromeda.config.components_path}/_#{underscored_name}.html.erb", partial_content
32
+ end
33
+
34
+ private
35
+
36
+ def underscored_name
37
+ name.underscore
38
+ end
39
+
40
+ def tag_name
41
+ name.camelize
42
+ end
43
+
44
+ def partial_content
45
+ <<~ERB
46
+ #{usage_comment}
47
+ <div class="#{underscored_name.dasherize}">
48
+ #{prop_lines.join("\n")}#{"\n" unless prop_lines.empty?} <%= content %>
49
+ </div>
50
+ ERB
51
+ end
52
+
53
+ def prop_lines
54
+ props.map do |prop|
55
+ local = prop.underscore
56
+ " <% if local_assigns[:#{local}] %><p class=\"#{underscored_name.dasherize}__#{local.dasherize}\">" \
57
+ "<%= #{local} %></p><% end %>"
58
+ end
59
+ end
60
+
61
+ def usage_comment
62
+ attrs = props.map { |prop| "#{prop.underscore.camelize(:lower)}=\"...\"" }.join(" ")
63
+ opening = attrs.empty? ? "<#{tag_name}>" : "<#{tag_name} #{attrs}>"
64
+
65
+ <<~ERB.chomp
66
+ <%# MDX usage:
67
+ #{opening}
68
+ Body text.
69
+ </#{tag_name}>
70
+ %>
71
+ ERB
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,21 @@
1
+ Description:
2
+ Imports an existing Astro project's content into this app: every
3
+ collection referenced by `src/content.config.ts` (or, failing that,
4
+ every directory under `src/content/`) into `app/content/<collection>/`,
5
+ `src/assets` into `app/assets/`, an `Andromeda::Entry` subclass per
6
+ collection under `app/models/content/`, and a starter partial for every
7
+ MDX component that does not have one yet.
8
+
9
+ Frontmatter keys are rewritten to snake_case and MDX import statements
10
+ are rewritten to the `content_components/...` partial-path form;
11
+ everything else is copied byte-for-byte.
12
+
13
+ Prints a migration report of what was copied/rewritten/generated, and
14
+ everything that still needs a human (unmapped schema fields, components
15
+ to implement, expressions the renderer cannot statically evaluate).
16
+
17
+ Example:
18
+ rails generate andromeda:import_astro ../my-astro-blog
19
+
20
+ rails generate andromeda:import_astro ../my-astro-blog --dry-run
21
+ Prints the same report without writing any files.
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Andromeda
6
+ module Generators
7
+ module ImportAstro
8
+ # Astro writes a resolved JSON Schema per collection to
9
+ # `.astro/collections/<name>.schema.json` whenever it has run at least
10
+ # once (`astro sync`/`astro dev`/`astro build`). This
11
+ # generator prefers that file over parsing `content.config.ts` by
12
+ # hand when it is available.
13
+ #
14
+ # In practice the two sources are complementary rather than one
15
+ # strictly superseding the other:
16
+ #
17
+ # - The JSON Schema is Astro's own fully-resolved output -- it reflects
18
+ # whatever Zod actually decided about `required`/type after unions,
19
+ # refinements, etc., which a regex over the TypeScript source can
20
+ # only approximate. It is therefore the more trustworthy source for
21
+ # **required-ness** and for telling `z.number()` (JSON Schema
22
+ # `"number"`) apart from a `.int()`-refined one (`"integer"`).
23
+ # - It cannot tell `image()`/`reference(...)` apart from a plain
24
+ # string, has no notion of a `.default()` *value*, and (being JSON)
25
+ # cannot express a Ruby-side type name at all. Those still need
26
+ # `ContentConfigConverter`'s read of the TypeScript.
27
+ #
28
+ # So `ImportAstroGenerator` always parses `content.config.ts` (it is
29
+ # the only source for the Astro-specific helpers and defaults), and
30
+ # additionally reads this file, when present, to correct
31
+ # required-ness and refine `z.number()` into `:integer`/`:float`. See
32
+ # `ImportAstroGenerator#refine_with_json_schema!`.
33
+ module AstroSchemaJson
34
+ module_function
35
+
36
+ # @param astro_root [String]
37
+ # @param collection_name [String]
38
+ # @return [Hash, nil] the parsed `properties`/`required` shape, or
39
+ # nil if Astro never generated one for this collection.
40
+ def read(astro_root, collection_name)
41
+ path = File.join(astro_root, ".astro/collections/#{collection_name}.schema.json")
42
+ return nil unless File.file?(path)
43
+
44
+ JSON.parse(File.read(path))
45
+ rescue JSON::ParserError
46
+ nil
47
+ end
48
+
49
+ # @param schema_json [Hash] as returned by `read`.
50
+ # @param field_name [String]
51
+ # @return [Boolean, nil] nil when the field is not described at all
52
+ # (the generator then leaves the TS-derived required-ness alone).
53
+ def required?(schema_json, field_name)
54
+ return nil unless schema_json.is_a?(Hash)
55
+
56
+ properties = schema_json["properties"]
57
+ return nil unless properties.is_a?(Hash) && properties.key?(field_name)
58
+
59
+ Array(schema_json["required"]).include?(field_name)
60
+ end
61
+
62
+ # @return [Symbol, nil] `:integer` or `:float` when the field is a
63
+ # JSON Schema number/integer, nil otherwise (including when the
64
+ # field is absent) so the generator only ever narrows a `:float`
65
+ # guess, never overrides an unrelated type.
66
+ def numeric_precision(schema_json, field_name)
67
+ return nil unless schema_json.is_a?(Hash)
68
+
69
+ property = (schema_json["properties"] || {})[field_name]
70
+ return nil unless property.is_a?(Hash)
71
+
72
+ case property["type"]
73
+ when "integer" then :integer
74
+ when "number" then :float
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Andromeda
4
+ module Generators
5
+ module ImportAstro
6
+ # Small bracket-matching helpers shared by ContentConfigConverter and
7
+ # MdxImportRewriter. `content.config.ts` is TypeScript, but the shapes
8
+ # this generator needs to recognize (Astro's collection-config shapes)
9
+ # are a tiny, regular subset of it -- matching parens/braces/brackets
10
+ # while skipping over string literals is enough to find where a call
11
+ # or object literal ends, without carrying an actual TS parser around
12
+ # (the same reasoning `Andromeda::Components::ImportScanner` already
13
+ # applies to MDX import statements).
14
+ module BalancedScanner
15
+ OPEN_TO_CLOSE = { "(" => ")", "{" => "}", "[" => "]" }.freeze
16
+ QUOTES = ["'", '"', "`"].freeze
17
+
18
+ module_function
19
+
20
+ # @param source [String]
21
+ # @param open_index [Integer] index of an opening bracket in `source`.
22
+ # @return [Integer, nil] the index of its matching closing bracket, or
23
+ # nil if `source` ends before one is found (malformed/truncated
24
+ # input -- callers treat that the same as "not found").
25
+ def matching_close(source, open_index)
26
+ open_char = source[open_index]
27
+ close_char = OPEN_TO_CLOSE.fetch(open_char)
28
+ depth = 0
29
+ i = open_index
30
+
31
+ while i < source.length
32
+ char = source[i]
33
+
34
+ if QUOTES.include?(char)
35
+ i = skip_string(source, i)
36
+ next
37
+ elsif char == open_char
38
+ depth += 1
39
+ elsif char == close_char
40
+ depth -= 1
41
+ return i if depth.zero?
42
+ end
43
+
44
+ i += 1
45
+ end
46
+
47
+ nil
48
+ end
49
+
50
+ # @return [Integer] the index just past the closing quote.
51
+ def skip_string(source, quote_index)
52
+ quote = source[quote_index]
53
+ i = quote_index + 1
54
+ while i < source.length
55
+ case source[i]
56
+ when "\\" then i += 1 # escaped char, skip it too
57
+ when quote then return i + 1
58
+ end
59
+ i += 1
60
+ end
61
+ i
62
+ end
63
+
64
+ # Splits `source` (the inside of a `{...}` or `(...)`, braces
65
+ # excluded) on top-level commas -- i.e. commas that are not nested
66
+ # inside their own brackets or a string literal. Used both for
67
+ # `z.object({ ... })` fields and for `z.enum([...])`/`z.array(...)`
68
+ # argument lists.
69
+ #
70
+ # @return [Array<String>] trimmed, non-empty segments (a trailing
71
+ # comma produces no empty segment).
72
+ def split_top_level(source)
73
+ segments = []
74
+ depth = 0
75
+ current = +""
76
+ i = 0
77
+
78
+ while i < source.length
79
+ char = source[i]
80
+
81
+ if QUOTES.include?(char)
82
+ stop = skip_string(source, i)
83
+ current << source[i...stop]
84
+ i = stop
85
+ next
86
+ elsif OPEN_TO_CLOSE.key?(char)
87
+ depth += 1
88
+ elsif OPEN_TO_CLOSE.value?(char)
89
+ depth -= 1
90
+ end
91
+
92
+ if char == "," && depth.zero?
93
+ segments << current
94
+ current = +""
95
+ else
96
+ current << char
97
+ end
98
+
99
+ i += 1
100
+ end
101
+ segments << current unless current.strip.empty?
102
+
103
+ segments.map(&:strip).reject(&:empty?)
104
+ end
105
+
106
+ # Strips `//line` and `/* block */` comments from `source`,
107
+ # preserving everything inside string literals untouched. Astro's
108
+ # own examples comment their schema fields (`// Transform string to
109
+ # Date object`) -- without this, such a comment would fuse onto the
110
+ # start of the next field's key when `split_top_level`/
111
+ # `split_key_value` see it as ordinary text.
112
+ def strip_comments(source)
113
+ result = +""
114
+ i = 0
115
+
116
+ while i < source.length
117
+ char = source[i]
118
+
119
+ if QUOTES.include?(char)
120
+ stop = skip_string(source, i)
121
+ result << source[i...stop]
122
+ i = stop
123
+ elsif source[i, 2] == "//"
124
+ i = (source.index("\n", i) || source.length)
125
+ elsif source[i, 2] == "/*"
126
+ close = source.index("*/", i + 2)
127
+ i = close ? close + 2 : source.length
128
+ else
129
+ result << char
130
+ i += 1
131
+ end
132
+ end
133
+
134
+ result
135
+ end
136
+
137
+ # Splits `field: expression` on the first top-level colon -- the key
138
+ # is always a bare identifier or a quoted string, never containing
139
+ # brackets, so the first depth-0 colon is unambiguous.
140
+ #
141
+ # @return [Array(String, String), nil] `[key, expression]`, or nil if
142
+ # no top-level colon is found at all.
143
+ def split_key_value(source)
144
+ depth = 0
145
+ i = 0
146
+ while i < source.length
147
+ char = source[i]
148
+
149
+ if QUOTES.include?(char)
150
+ i = skip_string(source, i)
151
+ next
152
+ elsif OPEN_TO_CLOSE.key?(char)
153
+ depth += 1
154
+ elsif OPEN_TO_CLOSE.value?(char)
155
+ depth -= 1
156
+ elsif char == ":" && depth.zero?
157
+ key = source[0...i].strip.gsub(/\A(['"])(.*)\1\z/, '\2')
158
+ return [key, source[(i + 1)..].strip]
159
+ end
160
+
161
+ i += 1
162
+ end
163
+ nil
164
+ end
165
+ end
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,232 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "balanced_scanner"
4
+
5
+ module Andromeda
6
+ module Generators
7
+ module ImportAstro
8
+ # Turns an Astro `src/content.config.ts` into the data
9
+ # `ImportAstroGenerator` needs to write one `Andromeda::Entry` subclass
10
+ # per collection.
11
+ #
12
+ # This is a static-analysis converter, not a TypeScript parser: Astro's
13
+ # own collection config is always one of a handful of call shapes
14
+ # (`defineCollection({ loader: glob({...}), schema: z.object({...}) })`,
15
+ # optionally with `schema: ({ image }) => z.object({...})`), so a
16
+ # regex + bracket-matching pass over the source text is enough to find
17
+ # them, the same trade-off `Andromeda::Components::ImportScanner`
18
+ # already makes for MDX import statements. Anything shaped differently
19
+ # falls through to `Attribute#unsupported?` rather than guessing.
20
+ module ContentConfigConverter
21
+ Collection = Struct.new(:name, :base, :pattern, :attributes, keyword_init: true)
22
+
23
+ # One frontmatter field. `ruby_type` is nil when the Zod expression
24
+ # could not be recognized at all -- `source` (the original
25
+ # expression text) is always kept so the generator can turn it into
26
+ # a TODO comment naming exactly what needs a human (per the
27
+ # generator's "never a silent omission" requirement).
28
+ Attribute = Struct.new(
29
+ :name, :ruby_type, :required, :default, :of, :values, :collection, :source, :note,
30
+ keyword_init: true
31
+ ) do
32
+ def unsupported? = ruby_type.nil?
33
+ end
34
+
35
+ # Astro's own base Zod types. Order matters: more specific
36
+ # patterns (`z.coerce.date()`) must be tried before the more general
37
+ # ones they could otherwise collide with in a naive match.
38
+ SCALAR_PATTERNS = [
39
+ [/\Az\.string\(\)\z/, :string],
40
+ [/\Az\.number\(\)\z/, :float],
41
+ [/\Az\.boolean\(\)\z/, :boolean],
42
+ [/\Az\.coerce\.date\(\)\z/, :date],
43
+ [/\Az\.date\(\)\z/, :date]
44
+ ].freeze
45
+
46
+ DEFAULT_LITERAL_RE = /\A(?:true|false|-?\d+(?:\.\d+)?|'[^']*'|"[^"]*"|\[[^\]]*\])\z/.freeze
47
+
48
+ module_function
49
+
50
+ # @param source [String] the full `content.config.ts` contents.
51
+ # @param astro_root [String] absolute path to the Astro project, used
52
+ # to resolve a `loader: glob({ base: ... })` path.
53
+ # @return [Array<Collection>]
54
+ def parse(source, astro_root:)
55
+ source.scan(/const\s+(\w+)\s*=\s*defineCollection\s*\(/).flatten.map do |const_name|
56
+ open = source.index("defineCollection", source.index("const #{const_name}"))
57
+ paren = source.index("(", open)
58
+ close = BalancedScanner.matching_close(source, paren)
59
+ body = source[(paren + 1)...close]
60
+
61
+ build_collection(const_name, body, astro_root: astro_root)
62
+ end
63
+ end
64
+
65
+ def build_collection(name, body, astro_root:)
66
+ body = BalancedScanner.strip_comments(body)
67
+ base = extract_string(body, /base\s*:/) || "./src/content/#{name}"
68
+ pattern = extract_string(body, /pattern\s*:/) || "**/*.{md,mdx}"
69
+
70
+ Collection.new(
71
+ name: name,
72
+ base: File.expand_path(base, astro_root),
73
+ pattern: pattern,
74
+ attributes: extract_attributes(body)
75
+ )
76
+ end
77
+
78
+ # @return [String, nil] the quoted value right after `label_re`, e.g.
79
+ # `base: './src/content/blog'` -> `"./src/content/blog"`.
80
+ def extract_string(body, label_re)
81
+ match = /#{label_re}\s*(['"])(.*?)\1/.match(body)
82
+ match && match[2]
83
+ end
84
+
85
+ def extract_attributes(body)
86
+ object_open = find_schema_object(body)
87
+ return [] unless object_open
88
+
89
+ close = BalancedScanner.matching_close(body, object_open)
90
+ fields_source = body[(object_open + 1)...close]
91
+
92
+ BalancedScanner.split_top_level(fields_source).filter_map do |field|
93
+ key, expr = BalancedScanner.split_key_value(field)
94
+ next if key.nil?
95
+
96
+ build_attribute(key, expr)
97
+ end
98
+ end
99
+
100
+ # Finds the `{` of `z.object({ ... })`, whether the schema is given
101
+ # directly (`schema: z.object({...})`) or behind the `({ image }) =>`
102
+ # helper form Astro's own examples use for `image()` fields.
103
+ def find_schema_object(body)
104
+ match = /schema\s*:.*?z\.object\s*\(\s*\{/m.match(body)
105
+ return nil unless match
106
+
107
+ match.end(0) - 1
108
+ end
109
+
110
+ def build_attribute(key, expr)
111
+ expr, required, default = strip_modifiers(expr)
112
+ type, extra = classify(expr)
113
+
114
+ Attribute.new(
115
+ name: key, ruby_type: type, required: type.nil? ? false : required, default: default,
116
+ of: extra[:of], values: extra[:values], collection: extra[:collection],
117
+ source: expr, note: extra[:note]
118
+ )
119
+ end
120
+
121
+ # Repeatedly peels `.optional()`/`z.optional(...)`/`.default(...)`
122
+ # wrappers off the outside of `expr` until none remain, tracking
123
+ # whether the field ended up optional and what its default literal
124
+ # is (Astro/Zod semantics: `.optional()` and `.default()` both make
125
+ # a field non-required; only `.default()` also supplies a value).
126
+ def strip_modifiers(expr)
127
+ required = true
128
+ default = nil
129
+
130
+ loop do
131
+ if expr.end_with?(".optional()")
132
+ expr = expr[0...-".optional()".length].strip
133
+ required = false
134
+ elsif (inner = unwrap_call(expr, "z.optional"))
135
+ expr = inner.strip
136
+ required = false
137
+ elsif (inner = suffix_call_argument(expr, ".default"))
138
+ default = literal_default(inner)
139
+ expr = expr[0...-(".default(#{inner})".length)].strip
140
+ required = false
141
+ else
142
+ break
143
+ end
144
+ end
145
+
146
+ [expr, required, default]
147
+ end
148
+
149
+ # @return [String, nil] the argument text of a trailing `.method(...)`
150
+ # call, if `expr` ends with exactly that call.
151
+ def suffix_call_argument(expr, method_name)
152
+ marker = "#{method_name}("
153
+ index = expr.rindex(marker)
154
+ return nil unless index
155
+
156
+ close = BalancedScanner.matching_close(expr, index + method_name.length)
157
+ return nil unless close == expr.length - 1
158
+
159
+ expr[(index + marker.length)...close]
160
+ end
161
+
162
+ # @return [String, nil] the argument text of `prefix(...)` when
163
+ # `expr` is exactly that call (not just contains it).
164
+ def unwrap_call(expr, prefix)
165
+ marker = "#{prefix}("
166
+ return nil unless expr.start_with?(marker)
167
+
168
+ close = BalancedScanner.matching_close(expr, prefix.length)
169
+ return nil unless close == expr.length - 1
170
+
171
+ expr[(prefix.length + 1)...close]
172
+ end
173
+
174
+ # Only a recognizable Ruby-literal-compatible default is carried
175
+ # over verbatim (JS and Ruby agree on `true`/`false`/numbers/quoted
176
+ # strings/arrays-of-those); anything else is dropped and the field
177
+ # falls back to being flagged unsupported by `classify` seeing an
178
+ # expression it was not built to trust the shape of.
179
+ def literal_default(text)
180
+ text.strip.match?(DEFAULT_LITERAL_RE) ? text.strip : nil
181
+ end
182
+
183
+ # @return [Array(Symbol, Hash), Array(nil, Hash)] the Ruby attribute
184
+ # type (nil if unrecognized) and any type-specific extras
185
+ # (`of:`/`values:`/`collection:`/`note:`).
186
+ def classify(expr)
187
+ SCALAR_PATTERNS.each do |pattern, type|
188
+ return [type, {}] if pattern.match?(expr)
189
+ end
190
+
191
+ if (inner = unwrap_call(expr, "z.enum"))
192
+ return classify_enum(inner)
193
+ end
194
+
195
+ if (inner = unwrap_call(expr, "z.array"))
196
+ return classify_array(inner)
197
+ end
198
+
199
+ return [:image, {}] if expr == "image()"
200
+
201
+ if (match = /\Areference\(\s*(['"])(.*?)\1\s*\)\z/.match(expr))
202
+ return [:reference, { collection: match[2].to_sym }]
203
+ end
204
+
205
+ [nil, {}]
206
+ end
207
+
208
+ def classify_enum(inner)
209
+ bracket_match = /\A\[(.*)\]\z/m.match(inner.strip)
210
+ return [nil, {}] unless bracket_match
211
+
212
+ values = BalancedScanner.split_top_level(bracket_match[1]).map do |literal|
213
+ literal.strip.gsub(/\A(['"])(.*)\1\z/, '\2')
214
+ end
215
+ [:enum, { values: values }]
216
+ end
217
+
218
+ # `of:` only carries over for a scalar element type Andromeda's own
219
+ # Schema supports for arrays (03-6) -- an array of something more
220
+ # exotic (`z.array(z.object({...}))`) is left with `of: nil` and a
221
+ # note, so the generator's TODO explains exactly why rather than
222
+ # silently guessing.
223
+ def classify_array(inner)
224
+ element_type, = classify(inner.strip)
225
+ return [:array, { of: element_type }] if element_type
226
+
227
+ [:array, { of: nil, note: "array element type `#{inner.strip}` is not a supported scalar" }]
228
+ end
229
+ end
230
+ end
231
+ end
232
+ end