andromeda_cms 0.1.0-aarch64-linux
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,313 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "andromeda_cms"
|
|
4
|
+
require "active_support/core_ext/string/inflections"
|
|
5
|
+
require_relative "content_config_converter"
|
|
6
|
+
require_relative "astro_schema_json"
|
|
7
|
+
require_relative "mdx_import_rewriter"
|
|
8
|
+
require_relative "mdx_content_scanner"
|
|
9
|
+
require_relative "../component/component_generator"
|
|
10
|
+
|
|
11
|
+
module Andromeda
|
|
12
|
+
module Generators
|
|
13
|
+
# `rails g andromeda:import_astro PATH_TO_ASTRO_PROJECT [--dry-run]`
|
|
14
|
+
# -- one-shot migration of an existing Astro
|
|
15
|
+
# project's content into a host app already set up with
|
|
16
|
+
# `andromeda:install`:
|
|
17
|
+
#
|
|
18
|
+
# 1. Copy every collection's content directory into `app/content/
|
|
19
|
+
# <collection>/`, and `src/assets` into `app/assets/`, rewriting
|
|
20
|
+
# only frontmatter keys and MDX
|
|
21
|
+
# import statements -- otherwise byte-for-byte.
|
|
22
|
+
# 2. Convert `src/content.config.ts` into one `Andromeda::Entry`
|
|
23
|
+
# subclass per collection, mapping the Zod shapes
|
|
24
|
+
# Astro supports; anything else becomes a commented-out line with a
|
|
25
|
+
# TODO rather than a silent omission.
|
|
26
|
+
# 3. Generate a partial stub for every MDX component tag that has no
|
|
27
|
+
# partial yet.
|
|
28
|
+
# 4. Print a migration report: what was copied/rewritten/generated,
|
|
29
|
+
# and everything that still needs a human.
|
|
30
|
+
#
|
|
31
|
+
# `--dry-run` runs the exact same analysis and prints the same report,
|
|
32
|
+
# but writes nothing -- implemented as Thor's own `--pretend`, which
|
|
33
|
+
# every `create_file`/`copy_file` call below already respects, rather
|
|
34
|
+
# than a second no-op code path to keep in sync with the real one.
|
|
35
|
+
class ImportAstroGenerator < Rails::Generators::Base
|
|
36
|
+
argument :astro_path, type: :string, banner: "PATH_TO_ASTRO_PROJECT"
|
|
37
|
+
class_option :dry_run, type: :boolean, default: false,
|
|
38
|
+
desc: "Print the migration report without writing any files"
|
|
39
|
+
|
|
40
|
+
def import
|
|
41
|
+
# Thor's own options Hash is frozen by the time a command runs, so
|
|
42
|
+
# `--dry-run` is translated into Thor's built-in `--pretend`
|
|
43
|
+
# (which every `create_file`/`copy_file` call below already
|
|
44
|
+
# respects) via a fresh, unfrozen Hash rather than an in-place
|
|
45
|
+
# mutation.
|
|
46
|
+
self.options = options.merge(pretend: true) if options[:dry_run]
|
|
47
|
+
|
|
48
|
+
@report = {
|
|
49
|
+
copied: [], frontmatter_renames: [], import_rewrites: [],
|
|
50
|
+
models: [], stubs: [], unsupported_schema: [], unsupported_expressions: [],
|
|
51
|
+
schema_json_used: [], notes: []
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
load_collections
|
|
55
|
+
copy_collections
|
|
56
|
+
copy_assets
|
|
57
|
+
generate_entry_classes
|
|
58
|
+
generate_component_stubs
|
|
59
|
+
print_report
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def astro_root
|
|
65
|
+
@astro_root ||= File.expand_path(astro_path)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def config_path
|
|
69
|
+
File.join(astro_root, "src/content.config.ts")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @return [Array<ImportAstro::ContentConfigConverter::Collection>]
|
|
73
|
+
def load_collections
|
|
74
|
+
@collections =
|
|
75
|
+
if File.file?(config_path)
|
|
76
|
+
ImportAstro::ContentConfigConverter.parse(File.read(config_path), astro_root: astro_root)
|
|
77
|
+
else
|
|
78
|
+
@report[:notes] << "no src/content.config.ts found -- falling back to every directory under " \
|
|
79
|
+
"src/content/ with no schema"
|
|
80
|
+
fallback_collections
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
@collections.each { |collection| refine_with_json_schema!(collection) }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# The fallback clause: every top-level directory under
|
|
87
|
+
# `src/content/` becomes a schema-less collection so the content
|
|
88
|
+
# itself is still migrated even without a `content.config.ts`.
|
|
89
|
+
def fallback_collections
|
|
90
|
+
base = File.join(astro_root, "src/content")
|
|
91
|
+
return [] unless Dir.exist?(base)
|
|
92
|
+
|
|
93
|
+
Dir.children(base).select { |name| File.directory?(File.join(base, name)) }.sort.map do |name|
|
|
94
|
+
ImportAstro::ContentConfigConverter::Collection.new(
|
|
95
|
+
name: name, base: File.join(base, name), pattern: "**/*.{md,mdx}", attributes: []
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Prefers the generated JSON Schema -- see astro_schema_json.rb
|
|
101
|
+
# for exactly what it can and cannot correct.
|
|
102
|
+
def refine_with_json_schema!(collection)
|
|
103
|
+
schema_json = ImportAstro::AstroSchemaJson.read(astro_root, collection.name)
|
|
104
|
+
return unless schema_json
|
|
105
|
+
|
|
106
|
+
@report[:schema_json_used] << collection.name
|
|
107
|
+
collection.attributes.each do |attr|
|
|
108
|
+
required = ImportAstro::AstroSchemaJson.required?(schema_json, attr.name)
|
|
109
|
+
attr.required = required unless required.nil?
|
|
110
|
+
|
|
111
|
+
next unless attr.ruby_type == :float
|
|
112
|
+
|
|
113
|
+
precision = ImportAstro::AstroSchemaJson.numeric_precision(schema_json, attr.name)
|
|
114
|
+
attr.ruby_type = precision if precision
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def copy_collections
|
|
119
|
+
@collections.each do |collection|
|
|
120
|
+
next unless Dir.exist?(collection.base)
|
|
121
|
+
|
|
122
|
+
Dir.glob(File.join(collection.base, "**/*")).sort.each do |src|
|
|
123
|
+
next unless File.file?(src)
|
|
124
|
+
|
|
125
|
+
relative = src.delete_prefix("#{collection.base}/")
|
|
126
|
+
dest = "app/content/#{collection.name}/#{relative}"
|
|
127
|
+
copy_content_file(src, dest)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Byte-for-byte except for the two rewrites below, and
|
|
133
|
+
# only for `.md`/`.mdx` files -- a sibling image living inside a
|
|
134
|
+
# directory-form entry (`my-post/cover.png`) is copied untouched.
|
|
135
|
+
def copy_content_file(src, dest)
|
|
136
|
+
extension = File.extname(src).downcase
|
|
137
|
+
unless %w[.md .mdx].include?(extension)
|
|
138
|
+
create_file dest, File.binread(src)
|
|
139
|
+
@report[:copied] << dest
|
|
140
|
+
return
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
source = File.read(src)
|
|
144
|
+
fixed, renames = Andromeda::Fix.fix_source(source)
|
|
145
|
+
renames.each { |from, to| @report[:frontmatter_renames] << "#{dest}: #{from} -> #{to}" }
|
|
146
|
+
|
|
147
|
+
if extension == ".mdx"
|
|
148
|
+
fixed, rewrites = ImportAstro::MdxImportRewriter.rewrite(fixed)
|
|
149
|
+
rewrites.each { |r| @report[:import_rewrites] << "#{dest}: #{r[:from]} -> #{r[:to]}" }
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
create_file dest, fixed
|
|
153
|
+
@report[:copied] << dest
|
|
154
|
+
|
|
155
|
+
scan_mdx_content(fixed, dest) if extension == ".mdx"
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def scan_mdx_content(source, dest)
|
|
159
|
+
(@component_usages ||= {}).merge!(
|
|
160
|
+
ImportAstro::MdxContentScanner.component_usages(source, path: dest)
|
|
161
|
+
) { |_name, existing, incoming| existing.props |= incoming.props; existing }
|
|
162
|
+
|
|
163
|
+
@report[:unsupported_expressions].concat(
|
|
164
|
+
ImportAstro::MdxContentScanner.unsupported_expressions(source, path: dest)
|
|
165
|
+
)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def copy_assets
|
|
169
|
+
assets_root = File.join(astro_root, "src/assets")
|
|
170
|
+
return unless Dir.exist?(assets_root)
|
|
171
|
+
|
|
172
|
+
Dir.glob(File.join(assets_root, "**/*")).sort.each do |src|
|
|
173
|
+
next unless File.file?(src)
|
|
174
|
+
|
|
175
|
+
relative = src.delete_prefix("#{assets_root}/")
|
|
176
|
+
dest = "app/assets/#{relative}"
|
|
177
|
+
create_file dest, File.binread(src)
|
|
178
|
+
@report[:copied] << dest
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def generate_entry_classes
|
|
183
|
+
@collections.each do |collection|
|
|
184
|
+
path = model_path(collection)
|
|
185
|
+
create_file path, model_content(collection)
|
|
186
|
+
@report[:models] << path
|
|
187
|
+
|
|
188
|
+
collection.attributes.select(&:unsupported?).each do |attr|
|
|
189
|
+
@report[:unsupported_schema] << "#{collection.name}.#{attr.name} (content.config.ts: #{attr.source})"
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def generate_component_stubs
|
|
195
|
+
return unless @component_usages
|
|
196
|
+
|
|
197
|
+
@component_usages.each_value do |usage|
|
|
198
|
+
next if partial_exists?(usage.name)
|
|
199
|
+
|
|
200
|
+
# Reuses the real `andromeda:component` generator (rather than
|
|
201
|
+
# duplicating its stub-content logic here) via Thor's own
|
|
202
|
+
# `invoke`, which shares this generator's `destination_root`
|
|
203
|
+
# automatically. `pretend:` is passed through explicitly --
|
|
204
|
+
# `invoke` only inherits a fresh invocation's *original* options
|
|
205
|
+
# (captured at construction time), not this instance's later
|
|
206
|
+
# `self.options = options.merge(pretend: true)` translation of
|
|
207
|
+
# `--dry-run` above.
|
|
208
|
+
invoke Andromeda::Generators::ComponentGenerator, [usage.name, *usage.props], pretend: options[:dry_run]
|
|
209
|
+
@report[:stubs] << component_partial_path(usage.name)
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def partial_exists?(name)
|
|
214
|
+
File.exist?(File.join(destination_root, component_partial_path(name)))
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def component_partial_path(name)
|
|
218
|
+
"#{Andromeda.config.components_path}/_#{Andromeda::Components.underscore(name)}.html.erb"
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def namespace_modules
|
|
222
|
+
@namespace_modules ||= Andromeda.config.entry_namespace.split("::")
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def model_path(collection)
|
|
226
|
+
File.join(Andromeda.config.entry_class_path, "#{collection.name.to_s.singularize}.rb")
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def model_content(collection)
|
|
230
|
+
indent = " " * namespace_modules.size
|
|
231
|
+
lines = ["# frozen_string_literal: true", ""]
|
|
232
|
+
namespace_modules.each_with_index { |mod, i| lines << "#{" " * i}module #{mod}" }
|
|
233
|
+
lines << "#{indent}class #{collection.name.to_s.singularize.camelize} < Andromeda::Entry"
|
|
234
|
+
lines << "#{indent} collection :#{collection.name}, base: \"app/content/#{collection.name}\", " \
|
|
235
|
+
"pattern: \"#{collection.pattern}\""
|
|
236
|
+
|
|
237
|
+
unless collection.attributes.empty?
|
|
238
|
+
lines << ""
|
|
239
|
+
collection.attributes.each { |attr| attribute_lines(attr).each { |line| lines << "#{indent} #{line}" } }
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
lines << "#{indent}end"
|
|
243
|
+
namespace_modules.size.downto(1) { |i| lines << "#{" " * (i - 1)}end" }
|
|
244
|
+
"#{lines.join("\n")}\n"
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# @return [Array<String>] one or more lines for this attribute: a
|
|
248
|
+
# real `attribute` declaration, or -- when the Zod expression could
|
|
249
|
+
# not be mapped -- a `# TODO` naming the original source plus a
|
|
250
|
+
# commented-out best guess, never a silent omission.
|
|
251
|
+
def attribute_lines(attr)
|
|
252
|
+
return unsupported_attribute_lines(attr) if attr.unsupported?
|
|
253
|
+
|
|
254
|
+
lines = []
|
|
255
|
+
lines << "# NOTE: #{attr.note}" if attr.note
|
|
256
|
+
lines << "attribute :#{snake_name(attr)}, :#{attr.ruby_type}#{attribute_modifiers(attr)}"
|
|
257
|
+
lines
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def unsupported_attribute_lines(attr)
|
|
261
|
+
[
|
|
262
|
+
"# TODO: unsupported schema for `#{attr.name}` in content.config.ts (#{attr.source}) -- " \
|
|
263
|
+
"add the equivalent `attribute` by hand.",
|
|
264
|
+
"# attribute :#{snake_name(attr)}, :string"
|
|
265
|
+
]
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# The generated class's `attribute` calls must use the same
|
|
269
|
+
# snake_case names `Andromeda::Fix` gives the frontmatter keys
|
|
270
|
+
# themselves -- `pubDate` in `content.config.ts` has to line
|
|
271
|
+
# up with `pub_date:` in the copied Markdown, or the schema would
|
|
272
|
+
# look for a key that no longer exists in the file.
|
|
273
|
+
def snake_name(attr)
|
|
274
|
+
Andromeda::Fix.snake_case(attr.name)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def attribute_modifiers(attr)
|
|
278
|
+
parts = []
|
|
279
|
+
parts << "of: :#{attr.of}" if attr.ruby_type == :array && attr.of
|
|
280
|
+
parts << "values: #{attr.values.inspect}" if attr.ruby_type == :enum
|
|
281
|
+
parts << "collection: :#{attr.collection}" if attr.ruby_type == :reference
|
|
282
|
+
parts << "required: true" if attr.required && attr.default.nil?
|
|
283
|
+
parts << "default: #{attr.default}" if attr.default
|
|
284
|
+
parts.empty? ? "" : ", #{parts.join(", ")}"
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def print_report
|
|
288
|
+
say ""
|
|
289
|
+
say(options[:dry_run] ? "Astro import (dry run -- nothing was written):" : "Astro import complete:")
|
|
290
|
+
say ""
|
|
291
|
+
report_section "Copied", @report[:copied]
|
|
292
|
+
report_section "Frontmatter keys renamed to snake_case", @report[:frontmatter_renames]
|
|
293
|
+
report_section "MDX import statements rewritten", @report[:import_rewrites]
|
|
294
|
+
report_section "Entry classes generated", @report[:models]
|
|
295
|
+
report_section "Component stubs generated", @report[:stubs]
|
|
296
|
+
report_section "Collections that used Astro's generated JSON Schema", @report[:schema_json_used]
|
|
297
|
+
say ""
|
|
298
|
+
say "Needs a human:"
|
|
299
|
+
report_section " Unmapped schema fields", @report[:unsupported_schema], indent: " "
|
|
300
|
+
report_section " Expressions the renderer cannot evaluate", @report[:unsupported_expressions], indent: " "
|
|
301
|
+
report_section " Notes", @report[:notes], indent: " "
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def report_section(title, items, indent: " ")
|
|
305
|
+
return if items.empty?
|
|
306
|
+
|
|
307
|
+
say "#{title}:"
|
|
308
|
+
items.each { |item| say "#{indent}#{item}" }
|
|
309
|
+
say ""
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../../andromeda/components/static_expression"
|
|
4
|
+
|
|
5
|
+
module Andromeda
|
|
6
|
+
module Generators
|
|
7
|
+
module ImportAstro
|
|
8
|
+
# Parses an already-copied `.mdx` file with the real Sätteri-backed
|
|
9
|
+
# `Andromeda::Parser` (rather than a second regex pass) to find:
|
|
10
|
+
#
|
|
11
|
+
# - every JSX component tag actually used, with the prop names seen on
|
|
12
|
+
# it (for generating partial stubs), and
|
|
13
|
+
# - every `{...}` expression that `Andromeda::Components::
|
|
14
|
+
# StaticExpression` -- the same evaluator the real renderer uses --
|
|
15
|
+
# cannot evaluate, for the migration report's "expressions the
|
|
16
|
+
# renderer cannot evaluate" section.
|
|
17
|
+
#
|
|
18
|
+
# This only inspects the tree; it never renders anything, so it needs
|
|
19
|
+
# no view layer and works the same whether or not the host app has
|
|
20
|
+
# partials yet.
|
|
21
|
+
module MdxContentScanner
|
|
22
|
+
ComponentUsage = Struct.new(:name, :props, keyword_init: true)
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
# @param source [String] MDX source, already import-rewritten (the
|
|
27
|
+
# rewrite does not change tag usage, so scan order does not
|
|
28
|
+
# matter).
|
|
29
|
+
# @param path [String] for error messages only.
|
|
30
|
+
# @return [Hash{String => ComponentUsage}] tag name -> usage,
|
|
31
|
+
# `Fragment` excluded (never a real component).
|
|
32
|
+
def component_usages(source, path:)
|
|
33
|
+
tree = Andromeda::Parser.parse(source, mdx: true, path: path)
|
|
34
|
+
usages = {}
|
|
35
|
+
|
|
36
|
+
walk(tree) do |node|
|
|
37
|
+
next unless %w[mdxJsxFlowElement mdxJsxTextElement].include?(node[:type])
|
|
38
|
+
|
|
39
|
+
name = node[:name]
|
|
40
|
+
next if name.nil? || name == "Fragment" || name.include?(".")
|
|
41
|
+
|
|
42
|
+
usage = (usages[name] ||= ComponentUsage.new(name: name, props: []))
|
|
43
|
+
(node[:attributes] || []).each do |attribute|
|
|
44
|
+
next unless attribute[:type] == "mdxJsxAttribute"
|
|
45
|
+
|
|
46
|
+
usage.props << attribute[:name] unless usage.props.include?(attribute[:name])
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
usages
|
|
51
|
+
rescue Andromeda::SyntaxError, Andromeda::ParserError
|
|
52
|
+
# Not this generator's job to diagnose a broken source file --
|
|
53
|
+
# `andromeda:check`/the renderer will raise the same error again,
|
|
54
|
+
# with the same message, once the file is actually rendered. The
|
|
55
|
+
# migration report calls this out instead of failing the import.
|
|
56
|
+
{}
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @return [Array<String>] one line per expression the renderer would
|
|
60
|
+
# raise on, `"path:line: {source}"`.
|
|
61
|
+
def unsupported_expressions(source, path:)
|
|
62
|
+
tree = Andromeda::Parser.parse(source, mdx: true, path: path)
|
|
63
|
+
problems = []
|
|
64
|
+
|
|
65
|
+
walk(tree) do |node|
|
|
66
|
+
next unless %w[mdxFlowExpression mdxTextExpression].include?(node[:type])
|
|
67
|
+
|
|
68
|
+
begin
|
|
69
|
+
Andromeda::Components::StaticExpression.evaluate(node[:value].to_s, frontmatter: {})
|
|
70
|
+
rescue Andromeda::Components::StaticExpression::UnsupportedExpressionError
|
|
71
|
+
line = node.dig(:position, :start, :line)
|
|
72
|
+
problems << "#{path}:#{line}: {#{node[:value]}}"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
problems
|
|
77
|
+
rescue Andromeda::SyntaxError, Andromeda::ParserError
|
|
78
|
+
[]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def walk(node, &block)
|
|
82
|
+
return unless node.is_a?(Hash)
|
|
83
|
+
|
|
84
|
+
yield node if node[:type]
|
|
85
|
+
(node[:children] || []).each { |child| walk(child, &block) }
|
|
86
|
+
end
|
|
87
|
+
private_class_method :walk
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Andromeda
|
|
4
|
+
module Generators
|
|
5
|
+
module ImportAstro
|
|
6
|
+
# Rewrites MDX import statements to the partial-path form:
|
|
7
|
+
# `import Callout from '../../components/Callout.astro';`
|
|
8
|
+
# becomes `import Callout from 'content_components/callout';`. Only
|
|
9
|
+
# component imports are rewritten -- an image import
|
|
10
|
+
# (`import cover from './cover.png'`) is left alone, since `cover` is
|
|
11
|
+
# bound to an asset, not resolved as a JSX tag (image imports are a
|
|
12
|
+
# separate, deferred concern from this rewrite).
|
|
13
|
+
#
|
|
14
|
+
# A regex pass over the raw text, matching
|
|
15
|
+
# `Andromeda::Components::ImportScanner`'s own reasoning: MDX imports
|
|
16
|
+
# are a small, regular subset of ES module syntax, and rewriting only
|
|
17
|
+
# ever needs to look at one import statement at a time.
|
|
18
|
+
module MdxImportRewriter
|
|
19
|
+
DEFAULT_IMPORT_RE = /^import\s+([A-Za-z_$][\w$]*)\s+from\s+(['"])(.*?)\2\s*;?[ \t]*$/
|
|
20
|
+
NAMED_IMPORT_RE = /^import\s*\{([^}]*)\}\s*from\s+(['"])(.*?)\2\s*;?[ \t]*$/
|
|
21
|
+
NAMED_BINDING_RE = /\A([A-Za-z_$][\w$]*)(?:\s+as\s+([A-Za-z_$][\w$]*))?\z/
|
|
22
|
+
IMAGE_EXTENSIONS = %w[.png .jpg .jpeg .gif .svg .webp .avif].freeze
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
# @param source [String] a whole `.mdx` file's contents.
|
|
27
|
+
# @return [Array(String, Array<Hash>)] the rewritten source and a
|
|
28
|
+
# list of `{from:, to:}` rewrites applied, for the migration
|
|
29
|
+
# report.
|
|
30
|
+
def rewrite(source)
|
|
31
|
+
rewrites = []
|
|
32
|
+
|
|
33
|
+
rewritten = source.gsub(NAMED_IMPORT_RE) do |whole|
|
|
34
|
+
rewrite_named(whole, Regexp.last_match(1), Regexp.last_match(3), rewrites)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
rewritten = rewritten.gsub(DEFAULT_IMPORT_RE) do |whole|
|
|
38
|
+
rewrite_default(whole, Regexp.last_match(1), Regexp.last_match(3), rewrites)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
[rewritten, rewrites]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def rewrite_default(whole, name, path, rewrites)
|
|
45
|
+
return whole unless component_name?(name) && !image_path?(path)
|
|
46
|
+
|
|
47
|
+
replacement = "import #{name} from '#{partial_path(name)}';"
|
|
48
|
+
rewrites << { from: whole.strip, to: replacement }
|
|
49
|
+
replacement
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# A named import list is rewritten only when every binding looks
|
|
53
|
+
# like a component (capitalized) -- one import path cannot honestly
|
|
54
|
+
# become several different partial paths, and a mixed list
|
|
55
|
+
# (components alongside a lowercase helper) is rare enough in
|
|
56
|
+
# practice that leaving it untouched (and letting naming-convention
|
|
57
|
+
# resolution handle the components at render time) is safer than
|
|
58
|
+
# guessing which names to split out.
|
|
59
|
+
def rewrite_named(whole, bindings_source, _path, rewrites)
|
|
60
|
+
bindings = bindings_source.split(",").filter_map { |b| b.strip unless b.strip.empty? }
|
|
61
|
+
locals = bindings.map { |binding| NAMED_BINDING_RE.match(binding) }
|
|
62
|
+
return whole if locals.any?(&:nil?)
|
|
63
|
+
|
|
64
|
+
local_names = locals.map { |m| m[2] || m[1] }
|
|
65
|
+
return whole unless local_names.all? { |name| component_name?(name) }
|
|
66
|
+
|
|
67
|
+
replacement = local_names.map { |name| "import #{name} from '#{partial_path(name)}';" }.join("\n")
|
|
68
|
+
rewrites << { from: whole.strip, to: replacement }
|
|
69
|
+
replacement
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def component_name?(name)
|
|
73
|
+
name.match?(/\A[A-Z]/)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def image_path?(path)
|
|
77
|
+
IMAGE_EXTENSIONS.include?(File.extname(path).downcase)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# @return [String] `content_components/<underscored name>`, matching
|
|
81
|
+
# the naming-convention `Andromeda::Components` already resolves
|
|
82
|
+
# tags with, minus the `app/views/` prefix a partial
|
|
83
|
+
# path never includes.
|
|
84
|
+
def partial_path(name)
|
|
85
|
+
base = Andromeda.config.components_path.sub(%r{\Aapp/views/}, "")
|
|
86
|
+
"#{base}/#{Andromeda::Components.underscore(name)}"
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Description:
|
|
2
|
+
Sets up a host application for Andromeda: creates app/content/ and
|
|
3
|
+
app/views/content_components/, writes a commented config/initializers/
|
|
4
|
+
andromeda.rb, and appends the paths Andromeda writes at build time
|
|
5
|
+
(/.andromeda/ and /app/assets/builds/andromeda/) to .gitignore and
|
|
6
|
+
.dockerignore.
|
|
7
|
+
|
|
8
|
+
Safe to run more than once: it never duplicates a line it already added.
|
|
9
|
+
|
|
10
|
+
Example:
|
|
11
|
+
rails generate andromeda:install
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "andromeda_cms"
|
|
4
|
+
|
|
5
|
+
module Andromeda
|
|
6
|
+
module Generators
|
|
7
|
+
# `rails g andromeda:install` -- one-time setup for a host
|
|
8
|
+
# application: the content directory, the component partials directory,
|
|
9
|
+
# a commented initializer, and the two ignore files expect
|
|
10
|
+
# `.andromeda/` (the conversion cache) and the copied-image build
|
|
11
|
+
# directory to be excluded from.
|
|
12
|
+
#
|
|
13
|
+
# Every step here is safe to run twice: directories/files Thor already
|
|
14
|
+
# knows about follow its normal conflict handling, and the ignore-file
|
|
15
|
+
# lines are only appended when they are not already present.
|
|
16
|
+
class InstallGenerator < Rails::Generators::Base
|
|
17
|
+
source_root File.expand_path("templates", __dir__)
|
|
18
|
+
|
|
19
|
+
IGNORE_LINES = ["/.andromeda/", "/app/assets/builds/andromeda/"].freeze
|
|
20
|
+
|
|
21
|
+
def create_content_directory
|
|
22
|
+
empty_directory "app/content"
|
|
23
|
+
create_file "app/content/.keep"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def create_components_directory
|
|
27
|
+
path = Andromeda.config.components_path
|
|
28
|
+
empty_directory path
|
|
29
|
+
create_file "#{path}/.keep"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def create_initializer
|
|
33
|
+
copy_file "initializer.rb", "config/initializers/andromeda.rb"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def update_ignore_files
|
|
37
|
+
[".gitignore", ".dockerignore"].each { |filename| append_ignore_lines(filename) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def show_next_steps
|
|
41
|
+
say ""
|
|
42
|
+
say "Andromeda is set up. Next steps:"
|
|
43
|
+
say ""
|
|
44
|
+
say " 1. Generate a collection:"
|
|
45
|
+
say " rails generate andromeda:collection posts title:string pub_date:date"
|
|
46
|
+
say ""
|
|
47
|
+
say " 2. Or import existing content from an Astro project:"
|
|
48
|
+
say " rails generate andromeda:import_astro path/to/astro-project"
|
|
49
|
+
say ""
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
# Appends whichever of IGNORE_LINES aren't already in `filename`, or
|
|
55
|
+
# explains why nothing happened when the file doesn't exist -- an
|
|
56
|
+
# app without Docker, for instance, has no .dockerignore and should
|
|
57
|
+
# not get one invented for it.
|
|
58
|
+
def append_ignore_lines(filename)
|
|
59
|
+
path = File.join(destination_root, filename)
|
|
60
|
+
|
|
61
|
+
unless File.exist?(path)
|
|
62
|
+
say_status :skip, "#{filename} not found -- if you add one later, also add: #{IGNORE_LINES.join(", ")}", :yellow
|
|
63
|
+
return
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
existing_lines = File.readlines(path, chomp: true)
|
|
67
|
+
missing = IGNORE_LINES.reject { |line| existing_lines.include?(line) }
|
|
68
|
+
if missing.empty?
|
|
69
|
+
say_status :identical, filename, :blue
|
|
70
|
+
return
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
append_to_file filename, "#{missing.join("\n")}\n"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Uncomment and edit any of these to override Andromeda's defaults -- see
|
|
4
|
+
# Andromeda::Configuration for the full description of each setting.
|
|
5
|
+
Andromeda.configure do |config|
|
|
6
|
+
# Where content files live, relative to the application root.
|
|
7
|
+
# config.content_path = "app/content"
|
|
8
|
+
|
|
9
|
+
# Namespace of the entry classes -- `Content::Post` lives at
|
|
10
|
+
# app/models/content/post.rb by default. Change this if your app already
|
|
11
|
+
# defines a top-level `Content`.
|
|
12
|
+
# config.entry_namespace = "Content"
|
|
13
|
+
|
|
14
|
+
# Directory holding the partials MDX components resolve to.
|
|
15
|
+
# config.components_path = "app/views/content_components"
|
|
16
|
+
|
|
17
|
+
# Where `andromeda:build` writes converted content. Hidden and ignored by
|
|
18
|
+
# git and Docker, like Astro's own .astro/.
|
|
19
|
+
# config.build_path = ".andromeda"
|
|
20
|
+
|
|
21
|
+
# Rouge theme used for code blocks; the default mirrors the Shiki theme
|
|
22
|
+
# Astro ships with.
|
|
23
|
+
# config.highlight_theme = "github.dark"
|
|
24
|
+
|
|
25
|
+
# :development (convert on demand, checking each source file's digest) or
|
|
26
|
+
# :production (read-only; raises Andromeda::BuildMissing if
|
|
27
|
+
# `andromeda:build` never ran). The Railtie sets this from Rails.env at
|
|
28
|
+
# boot, so overriding it here is rarely needed.
|
|
29
|
+
# config.mode = :development
|
|
30
|
+
end
|