andromeda_cms 0.7.1 → 0.8.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +50 -0
- data/lib/andromeda/components.rb +13 -1
- data/lib/andromeda/fix.rb +3 -1
- data/lib/andromeda/version.rb +1 -1
- data/lib/generators/andromeda/component/component_generator.rb +37 -4
- data/lib/generators/andromeda/import_astro/astro_schema_json.rb +4 -1
- data/lib/generators/andromeda/import_astro/content_config_converter.rb +144 -18
- data/lib/generators/andromeda/import_astro/import_astro_generator.rb +122 -24
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f5fcf2b6c7dc4e2ae34e6e729b2ce4c7b811c30b5ac1a330a371aa6bcf112114
|
|
4
|
+
data.tar.gz: b32192863c7a781f648e9ea4441f91841e21fbc792a33d7df2500df453a124f9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 376cc0430b6244ac65b3c6e112d4ed6e73a12c407af27a1cf3081f1bd352b17a3a7b2e861f6ec0b37f560bc25da5681120e931d20d47a679268eb643b9cea4ca
|
|
7
|
+
data.tar.gz: 6d0d3a7fe600e46a22a6165d317ff5186ba7028f5ed3f1fc0de7e13e0d1513580959582898b85b63aa992f86358abf9ff45108c9922562e1cbfd991cc89f4920
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,55 @@ All notable changes to this project are documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.8.0] - 2026-11-14
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- An MDX `import` now resolves only to a partial inside the components
|
|
13
|
+
directory (`Andromeda.config.components_path`). An import that points
|
|
14
|
+
anywhere else under `app/views`, such as a layout partial, is ignored and
|
|
15
|
+
the component falls back to its conventional partial, as an import that
|
|
16
|
+
does not resolve already did. Imported content could otherwise render
|
|
17
|
+
any partial in the application.
|
|
18
|
+
- `rails g andromeda:import_astro` skips a collection whose name cannot
|
|
19
|
+
become a valid Ruby class, such as a `src/content/blog-posts` directory or
|
|
20
|
+
`const BEGIN = defineCollection(...)`, and lists it in the report. These
|
|
21
|
+
used to produce an entry class that failed to load.
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- Security: `rails g andromeda:import_astro` no longer writes text from the
|
|
26
|
+
imported project into the generated entry classes as Ruby code. A
|
|
27
|
+
malicious Astro project could put code in a `.default(...)` value, a glob
|
|
28
|
+
`pattern`, a `reference(...)` name, a quoted field name or a schema
|
|
29
|
+
comment, and it ran every time the application loaded the class. Defaults
|
|
30
|
+
are now parsed into Ruby values, every imported value is written with
|
|
31
|
+
`inspect`, and text placed in comments is kept on one line. A default that
|
|
32
|
+
cannot be carried over is now noted in the class instead of silently
|
|
33
|
+
dropped.
|
|
34
|
+
- Security: `rails g andromeda:import_astro` copies only files that are
|
|
35
|
+
inside the Astro project. A `glob({ base: ... })` pointing elsewhere (such
|
|
36
|
+
as `~/.ssh`) or a symlink to a file outside the project used to copy that
|
|
37
|
+
file into the application. A glob `pattern` that leaves the collection
|
|
38
|
+
directory is replaced with the default pattern.
|
|
39
|
+
- `rails g andromeda:import_astro` generates a partial stub for every
|
|
40
|
+
component an MDX file uses, not only the first one, and none for
|
|
41
|
+
lowercase HTML tags.
|
|
42
|
+
- `rails g andromeda:import_astro` no longer stops partway through on an
|
|
43
|
+
invalid component name, a content file that is not valid UTF-8, or a
|
|
44
|
+
malformed generated JSON Schema. A `content.config.ts` that is not valid
|
|
45
|
+
UTF-8 stops the import before anything is written.
|
|
46
|
+
- `rails g andromeda:import_astro` no longer gives a collection the
|
|
47
|
+
schema of another collection whose name starts with the same text (`blog`
|
|
48
|
+
and `blogPosts`), and parses a config with many collections in linear
|
|
49
|
+
time.
|
|
50
|
+
- Renaming frontmatter keys (`andromeda:fix` and the Astro import) no longer
|
|
51
|
+
corrupts a file whose frontmatter contains backslash sequences such as
|
|
52
|
+
`\0` or `\&`.
|
|
53
|
+
- `rails g andromeda:component` turns a prop that is not a valid Ruby local
|
|
54
|
+
name (such as `class`, `a:b` or `content`) into a TODO comment instead of
|
|
55
|
+
writing a partial that fails to compile.
|
|
56
|
+
|
|
8
57
|
## [0.7.1] - 2026-11-08
|
|
9
58
|
|
|
10
59
|
### Changed
|
|
@@ -241,6 +290,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
241
290
|
- Generators: `andromeda:install`, `andromeda:collection`,
|
|
242
291
|
`andromeda:component` and `andromeda:import_astro`.
|
|
243
292
|
|
|
293
|
+
[0.8.0]: https://github.com/AndromedaCMS/andromeda_cms/releases/tag/v0.8.0
|
|
244
294
|
[0.7.1]: https://github.com/AndromedaCMS/andromeda_cms/releases/tag/v0.7.1
|
|
245
295
|
[0.7.0]: https://github.com/AndromedaCMS/andromeda_cms/releases/tag/v0.7.0
|
|
246
296
|
[0.6.3]: https://github.com/AndromedaCMS/andromeda_cms/releases/tag/v0.6.3
|
data/lib/andromeda/components.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
3
5
|
require_relative "components/errors"
|
|
4
6
|
require_relative "components/import_scanner"
|
|
5
7
|
require_relative "components/static_expression"
|
|
@@ -163,11 +165,21 @@ module Andromeda
|
|
|
163
165
|
end
|
|
164
166
|
|
|
165
167
|
import_path = @imports[name]
|
|
166
|
-
return import_path if import_path && partial_exists?(import_path)
|
|
168
|
+
return import_path if import_path && within_components_path?(import_path) && partial_exists?(import_path)
|
|
167
169
|
|
|
168
170
|
convention_partial(name)
|
|
169
171
|
end
|
|
170
172
|
|
|
173
|
+
# An `import` is author-controlled text; without this it could make the
|
|
174
|
+
# renderer render any partial under app/views (a layout partial, an
|
|
175
|
+
# admin one). Imports resolve only inside the components directory, and
|
|
176
|
+
# anything else falls back to convention like an unresolvable import.
|
|
177
|
+
def within_components_path?(partial)
|
|
178
|
+
base = Andromeda.config.components_path.sub(%r{\Aapp/views/}, "")
|
|
179
|
+
normalized = Pathname.new(partial).cleanpath.to_s
|
|
180
|
+
normalized.start_with?("#{base}/")
|
|
181
|
+
end
|
|
182
|
+
|
|
171
183
|
def convention_partial(name)
|
|
172
184
|
base = Andromeda.config.components_path.sub(%r{\Aapp/views/}, "")
|
|
173
185
|
"#{base}/#{self.class.underscore(name)}"
|
data/lib/andromeda/fix.rb
CHANGED
|
@@ -51,7 +51,9 @@ module Andromeda
|
|
|
51
51
|
line.sub(KEY_LINE) { "#{match[1]}#{match[2]}#{snake}#{match[2]}#{match[4]}" }
|
|
52
52
|
end.join
|
|
53
53
|
|
|
54
|
-
|
|
54
|
+
# Block form: a replacement string would interpret `\0`, `\&` and `\\`
|
|
55
|
+
# sequences in the frontmatter's own values and corrupt the file.
|
|
56
|
+
[source.sub(frontmatter) { fixed_frontmatter }, renames]
|
|
55
57
|
end
|
|
56
58
|
|
|
57
59
|
def snake_case(key)
|
data/lib/andromeda/version.rb
CHANGED
|
@@ -27,6 +27,17 @@ module Andromeda
|
|
|
27
27
|
"#{Andromeda.config.components_path}/_#{name.underscore}.html.erb"
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
+
# A prop becomes a Ruby local (`<%= title %>`, `locals: (title: nil)`),
|
|
31
|
+
# so it must be a plain identifier that is not a keyword. Props come
|
|
32
|
+
# from MDX attributes (`a:b`, `$x`, `class`), which are legal JSX but
|
|
33
|
+
# would produce a template that fails to compile.
|
|
34
|
+
LOCAL_NAME_RE = /\A[a-z_][a-zA-Z0-9_]*\z/
|
|
35
|
+
RESERVED_WORDS = %w[
|
|
36
|
+
__ENCODING__ __LINE__ __FILE__ BEGIN END alias and begin break case class def defined? do else elsif
|
|
37
|
+
end ensure false for if in module next nil not or redo rescue retry return self super then true undef
|
|
38
|
+
unless until when while yield
|
|
39
|
+
].freeze
|
|
40
|
+
|
|
30
41
|
def create_partial
|
|
31
42
|
create_file "#{Andromeda.config.components_path}/_#{underscored_name}.html.erb", partial_content
|
|
32
43
|
end
|
|
@@ -41,10 +52,32 @@ module Andromeda
|
|
|
41
52
|
name.camelize
|
|
42
53
|
end
|
|
43
54
|
|
|
55
|
+
# `content` is always declared for the tag's body (see
|
|
56
|
+
# `strict_locals`), so a prop of that name would be declared twice.
|
|
57
|
+
def valid_local?(prop)
|
|
58
|
+
local = prop.underscore
|
|
59
|
+
local.match?(LOCAL_NAME_RE) && !RESERVED_WORDS.include?(local) && local != "content"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def valid_props
|
|
63
|
+
props.select { |prop| valid_local?(prop) }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Rejected props are not emitted as code; a comment tells the author
|
|
67
|
+
# to rename them by hand. The prop text is attacker-influenced (it
|
|
68
|
+
# comes from MDX), so it must not be able to close the ERB tag or
|
|
69
|
+
# start a new line.
|
|
70
|
+
def rejected_prop_lines
|
|
71
|
+
props.reject { |prop| valid_local?(prop) }.map do |prop|
|
|
72
|
+
safe = prop.gsub(/\s+/, " ").gsub("%>", "")
|
|
73
|
+
"<%# TODO: prop `#{safe}` is not a valid local name -- rename it by hand. %>"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
44
77
|
def partial_content
|
|
45
78
|
<<~ERB
|
|
46
79
|
#{usage_comment}
|
|
47
|
-
#{strict_locals}
|
|
80
|
+
#{([strict_locals] + rejected_prop_lines).join("\n")}
|
|
48
81
|
<div class="#{underscored_name.dasherize}">
|
|
49
82
|
#{prop_lines.join("\n")}#{"\n" unless prop_lines.empty?} <%= content %>
|
|
50
83
|
</div>
|
|
@@ -54,12 +87,12 @@ module Andromeda
|
|
|
54
87
|
# Every prop is optional, and `content` is declared too: it is passed
|
|
55
88
|
# even to a self-closing tag (`<Callout />`), where it is empty.
|
|
56
89
|
def strict_locals
|
|
57
|
-
locals =
|
|
90
|
+
locals = valid_props.map { |prop| "#{prop.underscore}: nil" } << "content: nil"
|
|
58
91
|
"<%# locals: (#{locals.join(', ')}) %>"
|
|
59
92
|
end
|
|
60
93
|
|
|
61
94
|
def prop_lines
|
|
62
|
-
|
|
95
|
+
valid_props.map do |prop|
|
|
63
96
|
local = prop.underscore
|
|
64
97
|
" <% if local_assigns[:#{local}] %><p class=\"#{underscored_name.dasherize}__#{local.dasherize}\">" \
|
|
65
98
|
"<%= #{local} %></p><% end %>"
|
|
@@ -67,7 +100,7 @@ module Andromeda
|
|
|
67
100
|
end
|
|
68
101
|
|
|
69
102
|
def usage_comment
|
|
70
|
-
attrs =
|
|
103
|
+
attrs = valid_props.map { |prop| "#{prop.underscore.camelize(:lower)}=\"...\"" }.join(" ")
|
|
71
104
|
opening = attrs.empty? ? "<#{tag_name}>" : "<#{tag_name} #{attrs}>"
|
|
72
105
|
|
|
73
106
|
<<~ERB.chomp
|
|
@@ -66,7 +66,10 @@ module Andromeda
|
|
|
66
66
|
def numeric_precision(schema_json, field_name)
|
|
67
67
|
return nil unless schema_json.is_a?(Hash)
|
|
68
68
|
|
|
69
|
-
|
|
69
|
+
properties = schema_json["properties"]
|
|
70
|
+
return nil unless properties.is_a?(Hash)
|
|
71
|
+
|
|
72
|
+
property = properties[field_name]
|
|
70
73
|
return nil unless property.is_a?(Hash)
|
|
71
74
|
|
|
72
75
|
case property["type"]
|
|
@@ -28,6 +28,8 @@ module Andromeda
|
|
|
28
28
|
|
|
29
29
|
DEFAULT_PATTERN = "**/*.{md,mdx}"
|
|
30
30
|
|
|
31
|
+
COLLECTION_RE = /const\s+(\w+)\s*=\s*defineCollection\s*\(/.freeze
|
|
32
|
+
|
|
31
33
|
# Bash-style extglob openers (`!(drafts)`, `@(a|b)`, ...), which
|
|
32
34
|
# Astro's glob loader accepts but Ruby's `Dir.glob` does not.
|
|
33
35
|
EXTGLOB_RE = /[!@+*?]\(/.freeze
|
|
@@ -37,11 +39,16 @@ module Andromeda
|
|
|
37
39
|
# expression text) is always kept so the generator can turn it into
|
|
38
40
|
# a TODO comment naming exactly what needs a human (per the
|
|
39
41
|
# generator's "never a silent omission" requirement).
|
|
42
|
+
#
|
|
43
|
+
# `default` is an already-parsed Ruby value (never source text), and
|
|
44
|
+
# `has_default` says whether there is one at all -- `default: false`
|
|
45
|
+
# and "no default" must not collapse into the same nil.
|
|
40
46
|
Attribute = Struct.new(
|
|
41
|
-
:name, :ruby_type, :required, :default, :of, :values, :collection, :source, :note,
|
|
47
|
+
:name, :ruby_type, :required, :default, :has_default, :of, :values, :collection, :source, :note,
|
|
42
48
|
keyword_init: true
|
|
43
49
|
) do
|
|
44
50
|
def unsupported? = ruby_type.nil?
|
|
51
|
+
def default? = has_default ? true : false
|
|
45
52
|
end
|
|
46
53
|
|
|
47
54
|
# Astro's own base Zod types. Order matters: more specific
|
|
@@ -55,7 +62,25 @@ module Andromeda
|
|
|
55
62
|
[/\Az\.date\(\)\z/, :date]
|
|
56
63
|
].freeze
|
|
57
64
|
|
|
58
|
-
|
|
65
|
+
# Returned by `parse_literal` for anything it does not recognize, so
|
|
66
|
+
# that a literal `nil`/`false` default stays distinguishable.
|
|
67
|
+
UNPARSEABLE = Object.new.freeze
|
|
68
|
+
|
|
69
|
+
# The JS string escapes `parse_js_string` decodes. Any other
|
|
70
|
+
# backslash sequence makes the whole literal unparseable rather
|
|
71
|
+
# than risk decoding it differently from JS.
|
|
72
|
+
JS_STRING_ESCAPES = {
|
|
73
|
+
"\\" => "\\", "'" => "'", '"' => '"', "/" => "/", "$" => "$",
|
|
74
|
+
"n" => "\n", "t" => "\t", "r" => "\r", "b" => "\b", "f" => "\f", "v" => "\v"
|
|
75
|
+
}.freeze
|
|
76
|
+
|
|
77
|
+
# `\xHH`, `\uHHHH` and `\u{H...}`; `\0` only when no digit follows
|
|
78
|
+
# (`\01` is a legacy octal escape, which is not decoded).
|
|
79
|
+
JS_CODE_ESCAPE_RE = /\G(?:x(\h{2})|u(\h{4})|u\{(\h{1,6})\}|0(?!\d))/.freeze
|
|
80
|
+
|
|
81
|
+
# The field names the generator can declare: after
|
|
82
|
+
# `Andromeda::Fix.snake_case` these become plain Ruby symbols.
|
|
83
|
+
FIELD_NAME_RE = /\A[A-Za-z_][A-Za-z0-9_-]*\z/.freeze
|
|
59
84
|
|
|
60
85
|
module_function
|
|
61
86
|
|
|
@@ -64,13 +89,17 @@ module Andromeda
|
|
|
64
89
|
# to resolve a `loader: glob({ base: ... })` path.
|
|
65
90
|
# @return [Array<Collection>]
|
|
66
91
|
def parse(source, astro_root:)
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
92
|
+
# Each match's own position is used: looking the name up again
|
|
93
|
+
# with `index("const #{name}")` would find `const blog` inside
|
|
94
|
+
# `const blogPosts`, and rescanning from the top for every
|
|
95
|
+
# collection made a large file quadratic.
|
|
96
|
+
source.to_enum(:scan, COLLECTION_RE).filter_map do
|
|
97
|
+
match = Regexp.last_match
|
|
98
|
+
paren = match.end(0) - 1
|
|
70
99
|
close = BalancedScanner.matching_close(source, paren)
|
|
71
|
-
|
|
100
|
+
next unless close
|
|
72
101
|
|
|
73
|
-
build_collection(
|
|
102
|
+
build_collection(match[1], source[(paren + 1)...close], astro_root: astro_root)
|
|
74
103
|
end
|
|
75
104
|
end
|
|
76
105
|
|
|
@@ -79,6 +108,15 @@ module Andromeda
|
|
|
79
108
|
base = extract_string(body, /base\s*:/) || "./src/content/#{name}"
|
|
80
109
|
pattern, notes = extract_pattern(body)
|
|
81
110
|
|
|
111
|
+
# The pattern ends up in the generated class and is globbed from
|
|
112
|
+
# `app/content/<name>/` at runtime, so one that climbs out of it
|
|
113
|
+
# (`../../config/*`, `/etc/*`, `~/...`) is not carried over.
|
|
114
|
+
if escapes_base?(pattern)
|
|
115
|
+
notes << "pattern `#{pattern}` reaches outside the collection directory -- " \
|
|
116
|
+
"replaced with `#{DEFAULT_PATTERN}`"
|
|
117
|
+
pattern = DEFAULT_PATTERN
|
|
118
|
+
end
|
|
119
|
+
|
|
82
120
|
Collection.new(
|
|
83
121
|
name: name,
|
|
84
122
|
base: File.expand_path(base, astro_root),
|
|
@@ -131,6 +169,10 @@ module Andromeda
|
|
|
131
169
|
[pattern, notes]
|
|
132
170
|
end
|
|
133
171
|
|
|
172
|
+
def escapes_base?(pattern)
|
|
173
|
+
pattern.start_with?("/", "~") || pattern.split(%r{[/\\]}).include?("..")
|
|
174
|
+
end
|
|
175
|
+
|
|
134
176
|
def backticked(elements)
|
|
135
177
|
elements.map { |element| "`#{element}`" }.join(", ")
|
|
136
178
|
end
|
|
@@ -168,13 +210,30 @@ module Andromeda
|
|
|
168
210
|
end
|
|
169
211
|
|
|
170
212
|
def build_attribute(key, expr)
|
|
171
|
-
|
|
213
|
+
# A quoted key can hold anything (`"a; system('…')": ...`); only a
|
|
214
|
+
# name that is a plain identifier is declared, anything else goes
|
|
215
|
+
# to the TODO list.
|
|
216
|
+
unless key.match?(FIELD_NAME_RE)
|
|
217
|
+
return Attribute.new(
|
|
218
|
+
name: key, ruby_type: nil, required: false, source: expr,
|
|
219
|
+
note: "field name `#{key}` is not a valid attribute name"
|
|
220
|
+
)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
expr, required, default, has_default, dropped_default = strip_modifiers(expr)
|
|
172
224
|
type, extra = classify(expr)
|
|
173
225
|
|
|
226
|
+
notes = [extra[:note]]
|
|
227
|
+
if dropped_default
|
|
228
|
+
notes << "default `#{dropped_default}` is not a plain literal and was not carried over -- " \
|
|
229
|
+
"set `default:` by hand"
|
|
230
|
+
end
|
|
231
|
+
|
|
174
232
|
Attribute.new(
|
|
175
|
-
name: key, ruby_type: type, required: type.nil? ? false : required,
|
|
233
|
+
name: key, ruby_type: type, required: type.nil? ? false : required,
|
|
234
|
+
default: default, has_default: has_default,
|
|
176
235
|
of: extra[:of], values: extra[:values], collection: extra[:collection],
|
|
177
|
-
source: expr, note:
|
|
236
|
+
source: expr, note: notes.compact.join("; ").then { |note| note.empty? ? nil : note }
|
|
178
237
|
)
|
|
179
238
|
end
|
|
180
239
|
|
|
@@ -186,6 +245,8 @@ module Andromeda
|
|
|
186
245
|
def strip_modifiers(expr)
|
|
187
246
|
required = true
|
|
188
247
|
default = nil
|
|
248
|
+
has_default = false
|
|
249
|
+
dropped_default = nil
|
|
189
250
|
|
|
190
251
|
loop do
|
|
191
252
|
if expr.end_with?(".optional()")
|
|
@@ -195,7 +256,8 @@ module Andromeda
|
|
|
195
256
|
expr = inner.strip
|
|
196
257
|
required = false
|
|
197
258
|
elsif (inner = suffix_call_argument(expr, ".default"))
|
|
198
|
-
default = literal_default(inner)
|
|
259
|
+
has_default, default = literal_default(inner)
|
|
260
|
+
dropped_default = has_default ? nil : inner.strip
|
|
199
261
|
expr = expr[0...-(".default(#{inner})".length)].strip
|
|
200
262
|
required = false
|
|
201
263
|
else
|
|
@@ -203,7 +265,7 @@ module Andromeda
|
|
|
203
265
|
end
|
|
204
266
|
end
|
|
205
267
|
|
|
206
|
-
[expr, required, default]
|
|
268
|
+
[expr, required, default, has_default, dropped_default]
|
|
207
269
|
end
|
|
208
270
|
|
|
209
271
|
# @return [String, nil] the argument text of a trailing `.method(...)`
|
|
@@ -231,13 +293,77 @@ module Andromeda
|
|
|
231
293
|
expr[(prefix.length + 1)...close]
|
|
232
294
|
end
|
|
233
295
|
|
|
234
|
-
#
|
|
235
|
-
#
|
|
236
|
-
#
|
|
237
|
-
#
|
|
238
|
-
#
|
|
296
|
+
# Parses a `.default(...)` argument into a Ruby value: `true`/`false`,
|
|
297
|
+
# an integer, a float, a quoted string, or an array of those.
|
|
298
|
+
# Anything else is dropped. The text itself is never carried over --
|
|
299
|
+
# the generator writes the value back out with `inspect`, so a JS
|
|
300
|
+
# string like `"#{system('…')}"` can never become Ruby code.
|
|
301
|
+
#
|
|
302
|
+
# @return [Array(Boolean, Object)] whether a default was recognized,
|
|
303
|
+
# and its value.
|
|
239
304
|
def literal_default(text)
|
|
240
|
-
|
|
305
|
+
value = parse_literal(text.strip, allow_array: true)
|
|
306
|
+
value.equal?(UNPARSEABLE) ? [false, nil] : [true, value]
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def parse_literal(text, allow_array:)
|
|
310
|
+
case text
|
|
311
|
+
when "true" then true
|
|
312
|
+
when "false" then false
|
|
313
|
+
# A leading zero is left out: JS reads `0123` as legacy octal (83).
|
|
314
|
+
when /\A-?(?:0|[1-9]\d*)\z/ then Integer(text, 10)
|
|
315
|
+
when /\A-?(?:0|[1-9]\d*)\.\d+\z/
|
|
316
|
+
# A literal too large for a Float becomes `Infinity`, which
|
|
317
|
+
# `inspect` writes as an undefined constant.
|
|
318
|
+
value = Float(text)
|
|
319
|
+
value.finite? ? value : UNPARSEABLE
|
|
320
|
+
when /\A(['"]).*\1\z/m then parse_js_string(text)
|
|
321
|
+
when /\A\[.*\]\z/m
|
|
322
|
+
return UNPARSEABLE unless allow_array
|
|
323
|
+
|
|
324
|
+
values = BalancedScanner.split_top_level(text[1...-1]).map do |element|
|
|
325
|
+
parse_literal(element, allow_array: false)
|
|
326
|
+
end
|
|
327
|
+
values.any? { |value| value.equal?(UNPARSEABLE) } ? UNPARSEABLE : values
|
|
328
|
+
else
|
|
329
|
+
UNPARSEABLE
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# Decodes a single- or double-quoted JS string literal (including
|
|
334
|
+
# its quotes). Returns UNPARSEABLE for an unescaped closing quote or
|
|
335
|
+
# newline inside it, or an escape outside JS_STRING_ESCAPES.
|
|
336
|
+
def parse_js_string(text)
|
|
337
|
+
quote = text[0]
|
|
338
|
+
body = text[1...-1]
|
|
339
|
+
result = +""
|
|
340
|
+
i = 0
|
|
341
|
+
|
|
342
|
+
while i < body.length
|
|
343
|
+
char = body[i]
|
|
344
|
+
if char == "\\"
|
|
345
|
+
if (decoded = JS_STRING_ESCAPES[body[i + 1]])
|
|
346
|
+
result << decoded
|
|
347
|
+
i += 2
|
|
348
|
+
elsif (code = JS_CODE_ESCAPE_RE.match(body, i + 1))
|
|
349
|
+
codepoint = (code[1] || code[2] || code[3] || "0").to_i(16)
|
|
350
|
+
# A lone surrogate half (`\uD83D`) has no UTF-8 encoding.
|
|
351
|
+
return UNPARSEABLE if codepoint > 0x10FFFF || (0xD800..0xDFFF).cover?(codepoint)
|
|
352
|
+
|
|
353
|
+
result << codepoint.chr(Encoding::UTF_8)
|
|
354
|
+
i = code.end(0)
|
|
355
|
+
else
|
|
356
|
+
return UNPARSEABLE
|
|
357
|
+
end
|
|
358
|
+
elsif char == quote || char == "\n"
|
|
359
|
+
return UNPARSEABLE
|
|
360
|
+
else
|
|
361
|
+
result << char
|
|
362
|
+
i += 1
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
result
|
|
241
367
|
end
|
|
242
368
|
|
|
243
369
|
# @return [Array(Symbol, Hash), Array(nil, Hash)] the Ruby attribute
|
|
@@ -37,6 +37,12 @@ module Andromeda
|
|
|
37
37
|
class_option :dry_run, type: :boolean, default: false,
|
|
38
38
|
desc: "Print the migration report without writing any files"
|
|
39
39
|
|
|
40
|
+
# A collection name becomes `collection :name` and, singularized and
|
|
41
|
+
# camelized, the class name in the generated Ruby.
|
|
42
|
+
COLLECTION_NAME_RE = /\A[A-Za-z][A-Za-z0-9_]*\z/.freeze
|
|
43
|
+
CLASS_NAME_RE = /\A[A-Z][A-Za-z0-9_]*\z/.freeze
|
|
44
|
+
RESERVED_CLASS_NAMES = %w[BEGIN END].freeze
|
|
45
|
+
|
|
40
46
|
def import
|
|
41
47
|
# Thor's own options Hash is frozen by the time a command runs, so
|
|
42
48
|
# `--dry-run` is translated into Thor's built-in `--pretend`
|
|
@@ -71,15 +77,30 @@ module Andromeda
|
|
|
71
77
|
|
|
72
78
|
# @return [Array<ImportAstro::ContentConfigConverter::Collection>]
|
|
73
79
|
def load_collections
|
|
74
|
-
|
|
80
|
+
collections =
|
|
75
81
|
if File.file?(config_path)
|
|
76
|
-
|
|
82
|
+
source = File.read(config_path)
|
|
83
|
+
# Checked before anything is written, so a bad file stops the
|
|
84
|
+
# import cleanly instead of halfway through.
|
|
85
|
+
raise Thor::Error, "src/content.config.ts is not valid UTF-8" unless source.valid_encoding?
|
|
86
|
+
|
|
87
|
+
ImportAstro::ContentConfigConverter.parse(source, astro_root: astro_root)
|
|
77
88
|
else
|
|
78
89
|
@report[:notes] << "no src/content.config.ts found -- falling back to every directory under " \
|
|
79
90
|
"src/content/ with no schema"
|
|
80
91
|
fallback_collections
|
|
81
92
|
end
|
|
82
93
|
|
|
94
|
+
# Whether it came from `const <name> = defineCollection(...)` or a
|
|
95
|
+
# directory under src/content/, a name that cannot become valid
|
|
96
|
+
# Ruby (`blog-posts`, `BEGIN`, `_`, or `x; system('…') #` in a
|
|
97
|
+
# malicious theme) is skipped rather than written into code.
|
|
98
|
+
@collections, invalid = collections.partition { |collection| valid_collection_name?(collection.name) }
|
|
99
|
+
invalid.each do |collection|
|
|
100
|
+
@report[:notes] << "#{one_line(collection.name)}: skipped -- a collection name must be letters, digits " \
|
|
101
|
+
"and underscores, starting with a letter, and must make a valid class name"
|
|
102
|
+
end
|
|
103
|
+
|
|
83
104
|
@collections.each do |collection|
|
|
84
105
|
refine_with_json_schema!(collection)
|
|
85
106
|
collection.notes.each { |note| @report[:notes] << "#{collection.name}: #{note}" }
|
|
@@ -118,13 +139,46 @@ module Andromeda
|
|
|
118
139
|
end
|
|
119
140
|
end
|
|
120
141
|
|
|
142
|
+
def valid_collection_name?(name)
|
|
143
|
+
return false unless name.match?(COLLECTION_NAME_RE)
|
|
144
|
+
|
|
145
|
+
class_name = name.to_s.singularize.camelize
|
|
146
|
+
class_name.match?(CLASS_NAME_RE) && !RESERVED_CLASS_NAMES.include?(class_name)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Only files whose real path is inside the Astro project are copied.
|
|
150
|
+
# `glob({ base: '~/.ssh' })`, `base: '../..'`, or a symlink such as
|
|
151
|
+
# `src/assets/key -> ~/.ssh/id_rsa` in a malicious theme would
|
|
152
|
+
# otherwise copy files from elsewhere on the machine into the app.
|
|
153
|
+
def inside_astro_root?(path)
|
|
154
|
+
@real_astro_root ||= File.realpath(astro_root)
|
|
155
|
+
real = File.realpath(path)
|
|
156
|
+
real == @real_astro_root || real.start_with?("#{@real_astro_root}#{File::SEPARATOR}")
|
|
157
|
+
rescue SystemCallError
|
|
158
|
+
false
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def report_outside(path)
|
|
162
|
+
@report[:notes] << "#{one_line(path)}: not copied -- it resolves to a location outside the Astro project"
|
|
163
|
+
end
|
|
164
|
+
|
|
121
165
|
def copy_collections
|
|
122
166
|
@collections.each do |collection|
|
|
123
167
|
next unless Dir.exist?(collection.base)
|
|
124
168
|
|
|
169
|
+
unless inside_astro_root?(collection.base)
|
|
170
|
+
report_outside(collection.base)
|
|
171
|
+
next
|
|
172
|
+
end
|
|
173
|
+
|
|
125
174
|
Dir.glob(File.join(collection.base, "**/*")).sort.each do |src|
|
|
126
175
|
next unless File.file?(src)
|
|
127
176
|
|
|
177
|
+
unless inside_astro_root?(src)
|
|
178
|
+
report_outside(src.delete_prefix("#{astro_root}/"))
|
|
179
|
+
next
|
|
180
|
+
end
|
|
181
|
+
|
|
128
182
|
relative = src.delete_prefix("#{collection.base}/")
|
|
129
183
|
dest = "app/content/#{collection.name}/#{relative}"
|
|
130
184
|
copy_content_file(src, dest)
|
|
@@ -144,6 +198,13 @@ module Andromeda
|
|
|
144
198
|
end
|
|
145
199
|
|
|
146
200
|
source = File.read(src)
|
|
201
|
+
unless source.valid_encoding?
|
|
202
|
+
create_file dest, File.binread(src)
|
|
203
|
+
@report[:copied] << dest
|
|
204
|
+
@report[:notes] << "#{one_line(dest)}: not valid UTF-8 -- copied unchanged, without frontmatter or import rewrites"
|
|
205
|
+
return
|
|
206
|
+
end
|
|
207
|
+
|
|
147
208
|
fixed, renames = Andromeda::Fix.fix_source(source)
|
|
148
209
|
renames.each { |from, to| @report[:frontmatter_renames] << "#{dest}: #{from} -> #{to}" }
|
|
149
210
|
|
|
@@ -172,9 +233,19 @@ module Andromeda
|
|
|
172
233
|
assets_root = File.join(astro_root, "src/assets")
|
|
173
234
|
return unless Dir.exist?(assets_root)
|
|
174
235
|
|
|
236
|
+
unless inside_astro_root?(assets_root)
|
|
237
|
+
report_outside("src/assets")
|
|
238
|
+
return
|
|
239
|
+
end
|
|
240
|
+
|
|
175
241
|
Dir.glob(File.join(assets_root, "**/*")).sort.each do |src|
|
|
176
242
|
next unless File.file?(src)
|
|
177
243
|
|
|
244
|
+
unless inside_astro_root?(src)
|
|
245
|
+
report_outside(src.delete_prefix("#{astro_root}/"))
|
|
246
|
+
next
|
|
247
|
+
end
|
|
248
|
+
|
|
178
249
|
relative = src.delete_prefix("#{assets_root}/")
|
|
179
250
|
dest = "app/assets/#{relative}"
|
|
180
251
|
create_file dest, File.binread(src)
|
|
@@ -198,17 +269,26 @@ module Andromeda
|
|
|
198
269
|
return unless @component_usages
|
|
199
270
|
|
|
200
271
|
@component_usages.each_value do |usage|
|
|
272
|
+
# A lowercase tag is plain HTML to the renderer, not a component.
|
|
273
|
+
next unless usage.name.match?(/\A[A-Z]/)
|
|
274
|
+
|
|
275
|
+
unless usage.name.match?(Andromeda::Generators::ComponentGenerator::VALID_NAME_RE)
|
|
276
|
+
@report[:notes] << "<#{one_line(usage.name)}>: no partial stub generated -- the name is not a valid " \
|
|
277
|
+
"component name; add the partial by hand"
|
|
278
|
+
next
|
|
279
|
+
end
|
|
201
280
|
next if partial_exists?(usage.name)
|
|
202
281
|
|
|
203
|
-
# Reuses the real `andromeda:component` generator
|
|
204
|
-
# duplicating its stub-content logic here
|
|
205
|
-
#
|
|
206
|
-
#
|
|
207
|
-
#
|
|
208
|
-
#
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
282
|
+
# Reuses the real `andromeda:component` generator rather than
|
|
283
|
+
# duplicating its stub-content logic here. It is instantiated
|
|
284
|
+
# directly for every component: Thor's `invoke` runs a given
|
|
285
|
+
# generator class only once per run, so every stub after the
|
|
286
|
+
# first was silently skipped. `pretend:` is passed explicitly so
|
|
287
|
+
# `--dry-run` reaches it too.
|
|
288
|
+
Andromeda::Generators::ComponentGenerator.new(
|
|
289
|
+
[usage.name, *usage.props], { pretend: options[:dry_run] },
|
|
290
|
+
destination_root: destination_root, shell: shell
|
|
291
|
+
).invoke_all
|
|
212
292
|
@report[:stubs] << component_partial_path(usage.name)
|
|
213
293
|
end
|
|
214
294
|
end
|
|
@@ -235,10 +315,10 @@ module Andromeda
|
|
|
235
315
|
namespace_modules.each_with_index { |mod, i| lines << "#{" " * i}module #{mod}" }
|
|
236
316
|
lines << "#{indent}class #{collection.name.to_s.singularize.camelize} < Andromeda::Entry"
|
|
237
317
|
collection.notes.each do |note|
|
|
238
|
-
lines << "#{indent} # TODO: #{note} (content.config.ts) -- fix `pattern` by hand."
|
|
318
|
+
lines << "#{indent} # TODO: #{one_line(note)} (content.config.ts) -- fix `pattern` by hand."
|
|
239
319
|
end
|
|
240
320
|
lines << "#{indent} collection :#{collection.name}, base: \"app/content/#{collection.name}\", " \
|
|
241
|
-
"pattern:
|
|
321
|
+
"pattern: #{collection.pattern.inspect}"
|
|
242
322
|
|
|
243
323
|
unless collection.attributes.empty?
|
|
244
324
|
lines << ""
|
|
@@ -258,17 +338,25 @@ module Andromeda
|
|
|
258
338
|
return unsupported_attribute_lines(attr) if attr.unsupported?
|
|
259
339
|
|
|
260
340
|
lines = []
|
|
261
|
-
lines << "# NOTE: #{attr.note}" if attr.note
|
|
262
|
-
lines << "attribute
|
|
341
|
+
lines << "# NOTE: #{one_line(attr.note)}" if attr.note
|
|
342
|
+
lines << "attribute #{snake_name(attr).to_sym.inspect}, :#{attr.ruby_type}#{attribute_modifiers(attr)}"
|
|
263
343
|
lines
|
|
264
344
|
end
|
|
265
345
|
|
|
266
346
|
def unsupported_attribute_lines(attr)
|
|
267
|
-
[
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
347
|
+
lines = []
|
|
348
|
+
lines << "# NOTE: #{one_line(attr.note)}" if attr.note
|
|
349
|
+
lines << "# TODO: unsupported schema for `#{one_line(attr.name)}` in content.config.ts " \
|
|
350
|
+
"(#{one_line(attr.source)}) -- add the equivalent `attribute` by hand."
|
|
351
|
+
lines << "# attribute :#{one_line(snake_name(attr))}, :string"
|
|
352
|
+
lines
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# Everything taken from the imported project that ends up in a
|
|
356
|
+
# generated `#` comment goes through here: a newline would end the
|
|
357
|
+
# comment and turn the rest of the text into Ruby code.
|
|
358
|
+
def one_line(text)
|
|
359
|
+
text.to_s.gsub(/\s+/, " ")
|
|
272
360
|
end
|
|
273
361
|
|
|
274
362
|
# The generated class's `attribute` calls must use the same
|
|
@@ -280,13 +368,16 @@ module Andromeda
|
|
|
280
368
|
Andromeda::Fix.snake_case(attr.name)
|
|
281
369
|
end
|
|
282
370
|
|
|
371
|
+
# Every value taken from the imported project is written with
|
|
372
|
+
# `inspect`, never interpolated as source text, so nothing in
|
|
373
|
+
# content.config.ts can turn into Ruby code in the generated class.
|
|
283
374
|
def attribute_modifiers(attr)
|
|
284
375
|
parts = []
|
|
285
376
|
parts << "of: :#{attr.of}" if attr.ruby_type == :array && attr.of
|
|
286
377
|
parts << "values: #{attr.values.inspect}" if attr.ruby_type == :enum
|
|
287
|
-
parts << "collection:
|
|
288
|
-
parts << "required: true" if attr.required && attr.default
|
|
289
|
-
parts << "default: #{attr.default}" if attr.default
|
|
378
|
+
parts << "collection: #{attr.collection.inspect}" if attr.ruby_type == :reference
|
|
379
|
+
parts << "required: true" if attr.required && !attr.default?
|
|
380
|
+
parts << "default: #{attr.default.inspect}" if attr.default?
|
|
290
381
|
parts.empty? ? "" : ", #{parts.join(", ")}"
|
|
291
382
|
end
|
|
292
383
|
|
|
@@ -307,11 +398,18 @@ module Andromeda
|
|
|
307
398
|
report_section " Notes", @report[:notes], indent: " "
|
|
308
399
|
end
|
|
309
400
|
|
|
401
|
+
def printable(text)
|
|
402
|
+
text.to_s.gsub(/[[:cntrl:]]/) { |char| char.dump[1...-1] }
|
|
403
|
+
end
|
|
404
|
+
|
|
310
405
|
def report_section(title, items, indent: " ")
|
|
311
406
|
return if items.empty?
|
|
312
407
|
|
|
313
408
|
say "#{title}:"
|
|
314
|
-
|
|
409
|
+
# Items carry file names and text from the imported project; a
|
|
410
|
+
# control character (an ANSI escape, say) is shown escaped rather
|
|
411
|
+
# than sent to the terminal.
|
|
412
|
+
items.each { |item| say "#{indent}#{printable(item)}" }
|
|
315
413
|
say ""
|
|
316
414
|
end
|
|
317
415
|
end
|