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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +30 -0
- data/CONTRIBUTING.md +47 -0
- data/LICENSE.txt +21 -0
- data/README.md +276 -0
- data/lib/andromeda/assets.rb +128 -0
- data/lib/andromeda/check.rb +71 -0
- data/lib/andromeda/components/errors.rb +97 -0
- data/lib/andromeda/components/import_scanner.rb +72 -0
- data/lib/andromeda/components/static_expression.rb +79 -0
- data/lib/andromeda/components.rb +348 -0
- data/lib/andromeda/configuration.rb +91 -0
- data/lib/andromeda/entry.rb +294 -0
- data/lib/andromeda/errors.rb +173 -0
- data/lib/andromeda/fix.rb +71 -0
- data/lib/andromeda/frontmatter.rb +327 -0
- data/lib/andromeda/helpers.rb +84 -0
- data/lib/andromeda/id.rb +58 -0
- data/lib/andromeda/loader.rb +96 -0
- data/lib/andromeda/parser.rb +75 -0
- data/lib/andromeda/pipeline.rb +296 -0
- data/lib/andromeda/railtie.rb +47 -0
- data/lib/andromeda/registry.rb +127 -0
- data/lib/andromeda/relation.rb +125 -0
- data/lib/andromeda/renderer/code_highlighter.rb +53 -0
- data/lib/andromeda/renderer/errors.rb +52 -0
- data/lib/andromeda/renderer/literal_expression.rb +201 -0
- data/lib/andromeda/renderer.rb +411 -0
- data/lib/andromeda/schema.rb +383 -0
- data/lib/andromeda/slugger.rb +68 -0
- data/lib/andromeda/store.rb +286 -0
- data/lib/andromeda/tasks/andromeda.rake +56 -0
- data/lib/andromeda/version.rb +5 -0
- data/lib/andromeda_cms/3.2/andromeda_cms.so +0 -0
- data/lib/andromeda_cms/3.3/andromeda_cms.so +0 -0
- data/lib/andromeda_cms/3.4/andromeda_cms.so +0 -0
- data/lib/andromeda_cms/4.0/andromeda_cms.so +0 -0
- data/lib/andromeda_cms.rb +41 -0
- data/lib/generators/andromeda/collection/USAGE +25 -0
- data/lib/generators/andromeda/collection/collection_generator.rb +239 -0
- data/lib/generators/andromeda/component/USAGE +15 -0
- data/lib/generators/andromeda/component/component_generator.rb +75 -0
- data/lib/generators/andromeda/import_astro/USAGE +21 -0
- data/lib/generators/andromeda/import_astro/astro_schema_json.rb +80 -0
- data/lib/generators/andromeda/import_astro/balanced_scanner.rb +168 -0
- data/lib/generators/andromeda/import_astro/content_config_converter.rb +232 -0
- data/lib/generators/andromeda/import_astro/import_astro_generator.rb +313 -0
- data/lib/generators/andromeda/import_astro/mdx_content_scanner.rb +91 -0
- data/lib/generators/andromeda/import_astro/mdx_import_rewriter.rb +91 -0
- data/lib/generators/andromeda/install/USAGE +11 -0
- data/lib/generators/andromeda/install/install_generator.rb +77 -0
- data/lib/generators/andromeda/install/templates/initializer.rb +30 -0
- 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
|