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,294 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
module Andromeda
|
|
6
|
+
# The base class users subclass to define a collection:
|
|
7
|
+
#
|
|
8
|
+
# class Content::Post < Andromeda::Entry
|
|
9
|
+
# collection :blog, base: "app/content/blog", pattern: "**/*.{md,mdx}"
|
|
10
|
+
#
|
|
11
|
+
# attribute :title, :string, required: true
|
|
12
|
+
# attribute :pub_date, :date, required: true
|
|
13
|
+
# attribute :draft, :boolean, default: false
|
|
14
|
+
#
|
|
15
|
+
# scope :published, -> { where(draft: false) }
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# Content::Post.published.order(pub_date: :desc).limit(10)
|
|
19
|
+
# Content::Post.find("hello-world") # => Content::Post
|
|
20
|
+
# Content::Post.find("nope") # => raises Andromeda::EntryNotFound
|
|
21
|
+
#
|
|
22
|
+
# `Entry` only holds source-derived data (`id`/`collection`/`data`/`body`/
|
|
23
|
+
# `file_path`/`digest`) -- it does not render HTML. Rendering is the
|
|
24
|
+
# renderer's job and the store's; this class exists so
|
|
25
|
+
# they have something to render/persist in the first place.
|
|
26
|
+
class Entry
|
|
27
|
+
class << self
|
|
28
|
+
# @return [Symbol, nil] set by `collection`.
|
|
29
|
+
attr_reader :collection_name
|
|
30
|
+
# @return [String, nil] absolute path, set by `collection`.
|
|
31
|
+
attr_reader :base_dir
|
|
32
|
+
# @return [String, nil] a Dir.glob pattern relative to `base_dir`.
|
|
33
|
+
attr_reader :pattern
|
|
34
|
+
|
|
35
|
+
# Declares this class as the entry type for `name`, registering it in
|
|
36
|
+
# Andromeda::Registry so `Andromeda.get_collection`/`get_entry`
|
|
37
|
+
# and `reference` resolution can find it by name.
|
|
38
|
+
#
|
|
39
|
+
# @param name [Symbol, String]
|
|
40
|
+
# @param base [String] absolute, or relative to Rails.root (or the
|
|
41
|
+
# current working directory outside Rails).
|
|
42
|
+
# @param pattern [String] a Dir.glob pattern, relative to `base`.
|
|
43
|
+
def collection(name, base:, pattern: "**/*.{md,mdx}")
|
|
44
|
+
@collection_name = name.to_sym
|
|
45
|
+
@base_dir = resolve_base(base)
|
|
46
|
+
@pattern = pattern
|
|
47
|
+
@entries = nil
|
|
48
|
+
Andromeda::Registry.register(@collection_name, self)
|
|
49
|
+
self
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Declares one frontmatter attribute, delegating all type/validation
|
|
53
|
+
# logic to Andromeda::Schema (this class never reimplements it) and
|
|
54
|
+
# additionally defining an instance reader so `post.title` works as
|
|
55
|
+
# a shorthand for `post.data[:title]`.
|
|
56
|
+
#
|
|
57
|
+
# A `:reference` attribute gets a different reader: it resolves the
|
|
58
|
+
# stored `Andromeda::Reference` to the actual target entry (lazily,
|
|
59
|
+
# via the registry) rather than returning the pointer itself --
|
|
60
|
+
# `post.data[:author]` still holds the raw Reference for callers that
|
|
61
|
+
# want it.
|
|
62
|
+
#
|
|
63
|
+
# @see Andromeda::Schema#attribute for the full parameter list.
|
|
64
|
+
def attribute(name, type, **options)
|
|
65
|
+
schema.attribute(name, type, **options)
|
|
66
|
+
attr_name = name.to_sym
|
|
67
|
+
|
|
68
|
+
if type == :reference
|
|
69
|
+
define_method(attr_name) { resolve_reference(attr_name) }
|
|
70
|
+
else
|
|
71
|
+
define_method(attr_name) { data[attr_name] }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
self
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# `scope :published, -> { where(draft: false) }`. The block runs via
|
|
78
|
+
# `instance_exec` against an Andromeda::Relation (either `all`, at the
|
|
79
|
+
# class level, or an existing Relation when chained), so it can call
|
|
80
|
+
# `where`/`order`/other scopes unqualified.
|
|
81
|
+
#
|
|
82
|
+
# @param name [Symbol]
|
|
83
|
+
# @param callable [Proc]
|
|
84
|
+
def scope(name, callable)
|
|
85
|
+
name = name.to_sym
|
|
86
|
+
scopes[name] = callable
|
|
87
|
+
define_singleton_method(name) { |*args| all.public_send(name, *args) }
|
|
88
|
+
self
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# @return [Hash{Symbol => Proc}] public so Andromeda::Relation can look
|
|
92
|
+
# up a scope by name when one is chained off a Relation instance.
|
|
93
|
+
def scopes
|
|
94
|
+
@scopes ||= {}
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# @return [Andromeda::Schema] this class's own, never inherited/shared
|
|
98
|
+
# -- each subclass declares its own attributes independently.
|
|
99
|
+
def schema
|
|
100
|
+
@schema ||= Andromeda::Schema.new
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# @return [Andromeda::Relation] every loaded entry.
|
|
104
|
+
def all
|
|
105
|
+
Andromeda::Relation.new(entries, entry_class: self)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# @param id [String, Symbol]
|
|
109
|
+
# @return [Andromeda::Entry]
|
|
110
|
+
# @raise [Andromeda::EntryNotFound]
|
|
111
|
+
def find(id)
|
|
112
|
+
target = id.to_s
|
|
113
|
+
entries.find { |entry| entry.id == target } ||
|
|
114
|
+
raise(Andromeda::EntryNotFound.new(collection: collection_name, id: target, candidates: entries.map(&:id)))
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def find_by(conditions = nil, &block)
|
|
118
|
+
all.find_by(conditions, &block)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def where(conditions = nil, &block)
|
|
122
|
+
all.where(conditions, &block)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def order(*criteria)
|
|
126
|
+
all.order(*criteria)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def limit(count)
|
|
130
|
+
all.limit(count)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def first(count = nil)
|
|
134
|
+
all.first(count)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def count
|
|
138
|
+
entries.size
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# The list-view counterpart to `all`: in production
|
|
142
|
+
# this reads only `.andromeda/<collection>/_index.json` (no source
|
|
143
|
+
# file, no parsing), the same data an index page needs (`id`/`data`/
|
|
144
|
+
# `headings`/`digest`/`file_path`) without opening every entry's own
|
|
145
|
+
# JSON. In development it refreshes stale entries first so
|
|
146
|
+
# edits show up without a separate build step. Additive: `all`/
|
|
147
|
+
# `find`/`where`/etc. are unchanged and still read source files
|
|
148
|
+
# directly through Andromeda::Loader.
|
|
149
|
+
#
|
|
150
|
+
# @return [Array<Hash>]
|
|
151
|
+
# @raise [Andromeda::BuildMissing] in production, if nothing has been
|
|
152
|
+
# built for this collection yet.
|
|
153
|
+
def index
|
|
154
|
+
Andromeda::Pipeline.new.collection_index(self)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Drops the in-memory cache, forcing the next query to re-run the
|
|
158
|
+
# Loader. Used by tests that change fixtures mid-example, and is the
|
|
159
|
+
# hook the dev-mode "reconvert on stale mtime" check will call.
|
|
160
|
+
def reload!
|
|
161
|
+
@entries = nil
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
private
|
|
165
|
+
|
|
166
|
+
# Production never touches source files: everything a query
|
|
167
|
+
# needs was validated and written to `_index.json` at build time, so
|
|
168
|
+
# reading it back is both faster and immune to a content file that
|
|
169
|
+
# changed on disk after the build. Development goes to the Loader so
|
|
170
|
+
# edits are visible without a build step.
|
|
171
|
+
def entries
|
|
172
|
+
@entries ||= production? ? entries_from_index : load_entries_from_source
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def production?
|
|
176
|
+
Andromeda.config.mode == :production
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def load_entries_from_source
|
|
180
|
+
Andromeda::Loader.new(
|
|
181
|
+
entry_class: self, base: base_dir, pattern: pattern, schema: schema
|
|
182
|
+
).load
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# The index carries no body: rendering already happened at build time,
|
|
186
|
+
# and `Entry#html` reads the per-entry JSON rather than the body, so a
|
|
187
|
+
# listing never pays for content it does not show.
|
|
188
|
+
def entries_from_index
|
|
189
|
+
index.map do |summary|
|
|
190
|
+
summary = summary.transform_keys(&:to_sym)
|
|
191
|
+
new(
|
|
192
|
+
id: summary[:id],
|
|
193
|
+
collection: collection_name,
|
|
194
|
+
data: summary[:data],
|
|
195
|
+
body: nil,
|
|
196
|
+
file_path: summary[:file_path],
|
|
197
|
+
digest: summary[:digest]
|
|
198
|
+
)
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# `base:` is written as a project-relative string in every example
|
|
203
|
+
# (`"app/content/blog"`) so it reads the same in an initializer-free
|
|
204
|
+
# Rails app either way; resolving it here (rather than deferring to
|
|
205
|
+
# the Loader) keeps the Loader itself Rails-agnostic and testable with
|
|
206
|
+
# plain absolute paths.
|
|
207
|
+
def resolve_base(base)
|
|
208
|
+
return base if Pathname.new(base).absolute?
|
|
209
|
+
|
|
210
|
+
File.join(Andromeda.config.project_root, base)
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# @return [String]
|
|
215
|
+
attr_reader :id
|
|
216
|
+
# @return [Symbol]
|
|
217
|
+
attr_reader :collection
|
|
218
|
+
# @return [Hash] Symbol-keyed validated frontmatter (may also carry
|
|
219
|
+
# String keys for unrecognized frontmatter -- see Schema::Result).
|
|
220
|
+
attr_reader :data
|
|
221
|
+
# @return [String] raw Markdown/MDX body, frontmatter stripped but with
|
|
222
|
+
# original line numbers preserved (Andromeda::Frontmatter#parse).
|
|
223
|
+
attr_reader :body
|
|
224
|
+
# @return [String] absolute path to the source file.
|
|
225
|
+
attr_reader :file_path
|
|
226
|
+
# @return [String] SHA-256 of the whole source file, for cache keys.
|
|
227
|
+
attr_reader :digest
|
|
228
|
+
|
|
229
|
+
def initialize(id:, collection:, data:, body:, file_path:, digest:)
|
|
230
|
+
@id = id
|
|
231
|
+
@collection = collection
|
|
232
|
+
@data = data
|
|
233
|
+
@body = body
|
|
234
|
+
@file_path = file_path
|
|
235
|
+
@digest = digest
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# The rendered HTML for `body` (MDX components expanded),
|
|
239
|
+
# produced and cached by Andromeda::Pipeline rather than here --
|
|
240
|
+
# this method only decides *when* to ask for it. Memoized on the
|
|
241
|
+
# instance (not globally): a fresh Entry is what a reload/re-Loader-pass
|
|
242
|
+
# produces, carrying today's `digest`, so per-instance memoization can
|
|
243
|
+
# never serve a stale render across a reload the way a class-level or
|
|
244
|
+
# process-wide cache could (see Configuration#mode's dev-mode note).
|
|
245
|
+
#
|
|
246
|
+
# @return [String]
|
|
247
|
+
# @raise [Andromeda::BuildMissing] in production, if this entry has
|
|
248
|
+
# never been built.
|
|
249
|
+
def html
|
|
250
|
+
converted[:html]
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# @return [Array<Hash>] `{depth:, slug:, text:}` per heading, in
|
|
254
|
+
# document order (Andromeda::Renderer::Result#headings).
|
|
255
|
+
# @raise [Andromeda::BuildMissing] in production, if this entry has
|
|
256
|
+
# never been built.
|
|
257
|
+
def headings
|
|
258
|
+
converted[:headings]
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# @return [Hash] everything needed to rebuild this entry with `from_h`,
|
|
262
|
+
# so the build step can persist it (`.andromeda/<collection>/<id>.json`) and
|
|
263
|
+
# restore it later without re-parsing/re-validating the source file.
|
|
264
|
+
def to_h
|
|
265
|
+
{ id: id, collection: collection, data: data, body: body, file_path: file_path, digest: digest }
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# @param hash [Hash] as produced by `to_h` (String or Symbol keys).
|
|
269
|
+
# @return [Andromeda::Entry]
|
|
270
|
+
def self.from_h(hash)
|
|
271
|
+
new(**hash.transform_keys(&:to_sym))
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
private
|
|
275
|
+
|
|
276
|
+
def converted
|
|
277
|
+
@converted ||= Andromeda::Pipeline.new.fetch(self)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# @param attr_name [Symbol] a `:reference`-typed attribute.
|
|
281
|
+
# @return [Andromeda::Entry, nil]
|
|
282
|
+
# @raise [Andromeda::EntryNotFound] naming both the target collection
|
|
283
|
+
# and id, via Entry.find on the referenced collection.
|
|
284
|
+
def resolve_reference(attr_name)
|
|
285
|
+
reference = data[attr_name]
|
|
286
|
+
return nil if reference.nil?
|
|
287
|
+
|
|
288
|
+
# Only reached when the caller actually asks for `post.author` --
|
|
289
|
+
# deliberately not resolved (or even looked up) at load time, so
|
|
290
|
+
# referencing an entry never forces every other collection to load.
|
|
291
|
+
Andromeda.get_entry(reference.collection, reference.id)
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
end
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Andromeda
|
|
4
|
+
# Base class so host applications can rescue everything this gem raises.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Raised when Markdown or MDX cannot be parsed. Carries the source location so
|
|
8
|
+
# editors and CI logs can point at the offending line rather than the file.
|
|
9
|
+
class SyntaxError < Error
|
|
10
|
+
attr_reader :path, :line, :column, :raw_message
|
|
11
|
+
|
|
12
|
+
def initialize(message, path: nil, line: nil, column: nil)
|
|
13
|
+
@path = path
|
|
14
|
+
@line = line
|
|
15
|
+
@column = column
|
|
16
|
+
# Kept so the error can be rebuilt once the path is known without having
|
|
17
|
+
# to strip the location back out of the formatted message.
|
|
18
|
+
@raw_message = message
|
|
19
|
+
super(location ? "#{location}: #{message}" : message)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Returns a copy of this error attributed to `path`.
|
|
23
|
+
def at(path)
|
|
24
|
+
self.class.new(raw_message, path: path, line: line, column: column)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def location
|
|
28
|
+
return nil unless path || line
|
|
29
|
+
|
|
30
|
+
[path, line, column].compact.join(":")
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Raised when the parser itself fails unexpectedly. Sätteri is pre-1.0, so a
|
|
35
|
+
# panic is converted into this instead of aborting the whole Ruby process.
|
|
36
|
+
class ParserError < Error; end
|
|
37
|
+
|
|
38
|
+
# Raised by Schema#validate! when frontmatter fails validation. Carries
|
|
39
|
+
# every Schema::Problem found (Zod-style: report everything in one pass)
|
|
40
|
+
# so a fix-and-rerun cycle can address all of them instead of one at a
|
|
41
|
+
# time, and names the offending file when a path is available so the
|
|
42
|
+
# message is actionable straight out of a build log.
|
|
43
|
+
class ValidationError < Error
|
|
44
|
+
attr_reader :problems, :path
|
|
45
|
+
|
|
46
|
+
def initialize(problems, path: nil)
|
|
47
|
+
@problems = problems
|
|
48
|
+
@path = path
|
|
49
|
+
lines = problems.map { |problem| path ? "#{path}: #{problem.message}" : problem.message }
|
|
50
|
+
super(lines.join("\n"))
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Raised by Loader#load when one or more files fail to load (validation
|
|
55
|
+
# failures, syntax errors, duplicate ids). Like ValidationError, this
|
|
56
|
+
# collects everything found across every file in one build rather than
|
|
57
|
+
# stopping at the first bad file, so a fix-and-rerun cycle can address all
|
|
58
|
+
# of them at once instead of playing whack-a-mole one file at a time.
|
|
59
|
+
class LoaderError < Error
|
|
60
|
+
attr_reader :messages
|
|
61
|
+
|
|
62
|
+
def initialize(messages)
|
|
63
|
+
@messages = messages
|
|
64
|
+
super(messages.join("\n"))
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Raised by Entry.find / Andromeda.get_entry when no entry has the given
|
|
69
|
+
# id. Lists near-miss ids (by edit distance) when any are close enough to
|
|
70
|
+
# plausibly be a typo -- e.g. `find("hello-wrold")` should hint at
|
|
71
|
+
# `hello-world` rather than just saying "not found".
|
|
72
|
+
class EntryNotFound < Error
|
|
73
|
+
attr_reader :collection, :id, :candidates
|
|
74
|
+
|
|
75
|
+
def initialize(collection:, id:, candidates: [])
|
|
76
|
+
@collection = collection
|
|
77
|
+
@id = id
|
|
78
|
+
@candidates = candidates
|
|
79
|
+
|
|
80
|
+
message = "no #{collection.inspect} entry with id #{id.to_s.inspect}"
|
|
81
|
+
hints = near_misses(id.to_s, candidates)
|
|
82
|
+
message += ". Did you mean #{hints.map(&:inspect).join(" or ")}?" unless hints.empty?
|
|
83
|
+
super(message)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
# Only surfaces candidates within a small edit distance -- a long list
|
|
89
|
+
# of unrelated ids would be noise, not a hint.
|
|
90
|
+
def near_misses(id, candidates, max: 3, threshold: 4)
|
|
91
|
+
candidates
|
|
92
|
+
.map { |candidate| [candidate, levenshtein_distance(id, candidate)] }
|
|
93
|
+
.select { |_, distance| distance <= threshold }
|
|
94
|
+
.sort_by { |_, distance| distance }
|
|
95
|
+
.first(max)
|
|
96
|
+
.map(&:first)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Classic Wagner-Fischer DP. No gem dependency for what is, at v0 scale
|
|
100
|
+
# (everything held in memory), a handful of short string comparisons.
|
|
101
|
+
def levenshtein_distance(a, b)
|
|
102
|
+
return b.length if a.empty?
|
|
103
|
+
return a.length if b.empty?
|
|
104
|
+
|
|
105
|
+
costs = (0..b.length).to_a
|
|
106
|
+
a.each_char.with_index(1) do |char_a, i|
|
|
107
|
+
last_diagonal = costs[0]
|
|
108
|
+
costs[0] = i
|
|
109
|
+
b.each_char.with_index(1) do |char_b, j|
|
|
110
|
+
old_cost = costs[j]
|
|
111
|
+
costs[j] = if char_a == char_b
|
|
112
|
+
last_diagonal
|
|
113
|
+
else
|
|
114
|
+
[costs[j] + 1, costs[j - 1] + 1, last_diagonal + 1].min
|
|
115
|
+
end
|
|
116
|
+
last_diagonal = old_cost
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
costs.last
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Raised by Andromeda::Pipeline#fetch/#collection_index in production mode
|
|
124
|
+
# when a request asks for an entry/collection that
|
|
125
|
+
# `andromeda:build` has never converted -- production never converts on
|
|
126
|
+
# demand, so this is the "you forgot to build" error rather than a
|
|
127
|
+
# transient failure, and the message says exactly what fixes it.
|
|
128
|
+
class BuildMissing < Error
|
|
129
|
+
attr_reader :collection, :id, :file_path
|
|
130
|
+
|
|
131
|
+
def initialize(collection:, id: nil, file_path: nil)
|
|
132
|
+
@collection = collection
|
|
133
|
+
@id = id
|
|
134
|
+
@file_path = file_path
|
|
135
|
+
|
|
136
|
+
target = id ? "#{collection}/#{id}" : collection.to_s
|
|
137
|
+
source = file_path ? " (source: #{file_path})" : ""
|
|
138
|
+
super(
|
|
139
|
+
"no converted output for #{target.inspect}#{source} -- run `bin/rails andromeda:build` to convert " \
|
|
140
|
+
"content into .andromeda/ (this also happens automatically as part of `assets:precompile`)"
|
|
141
|
+
)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Raised by Andromeda::Pipeline#build_collection/.build_all (and
|
|
146
|
+
# #refresh_collection in development) when one or more entries fail to
|
|
147
|
+
# convert -- a schema problem, an unregistered MDX component, a syntax
|
|
148
|
+
# error, and so on. Like Andromeda::LoaderError, every problem found is
|
|
149
|
+
# collected first and raised together, so a build reports everything in
|
|
150
|
+
# one pass instead of a fix-and-rerun cycle one file at a time.
|
|
151
|
+
class BuildError < Error
|
|
152
|
+
attr_reader :messages
|
|
153
|
+
|
|
154
|
+
def initialize(messages)
|
|
155
|
+
@messages = messages
|
|
156
|
+
super(messages.join("\n"))
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Raised by Andromeda.get_collection / Andromeda.get_entry when no Entry
|
|
161
|
+
# subclass has registered the given collection name (see
|
|
162
|
+
# Andromeda::Registry) -- most often because its class has not been
|
|
163
|
+
# loaded yet in a development boot where app/ is not eager loaded.
|
|
164
|
+
class UnknownCollection < Error
|
|
165
|
+
def initialize(name)
|
|
166
|
+
super(
|
|
167
|
+
"no collection registered for #{name.inspect} -- does an Andromeda::Entry " \
|
|
168
|
+
"subclass call `collection #{name.inspect}, base: ...`? In development this " \
|
|
169
|
+
"class must be loaded first; see Andromeda::Registry.discover"
|
|
170
|
+
)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Andromeda
|
|
4
|
+
# Rewrites frontmatter keys that are not snake_case, the one migration
|
|
5
|
+
# step content copied from an Astro project reliably needs.
|
|
6
|
+
#
|
|
7
|
+
# The rewrite is textual on purpose: round-tripping through a YAML/TOML
|
|
8
|
+
# emitter would reformat quoting, key order and comments, turning a
|
|
9
|
+
# two-key rename into an unreviewable diff.
|
|
10
|
+
module Fix
|
|
11
|
+
KEY_LINE = /\A(\s*)(["']?)([A-Za-z_][A-Za-z0-9_-]*)\2(\s*[:=]\s*)/
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# @param entry_classes [Array<Class>] defaults to every registered collection.
|
|
16
|
+
# @param write [Boolean] false previews the changes without touching disk.
|
|
17
|
+
# @return [Array<String>] one line per renamed key, ready to print.
|
|
18
|
+
def run(entry_classes = Andromeda::Pipeline.default_entry_classes, write: true)
|
|
19
|
+
changes = []
|
|
20
|
+
|
|
21
|
+
content_files(entry_classes).each do |path|
|
|
22
|
+
source = File.read(path)
|
|
23
|
+
fixed, renames = fix_source(source)
|
|
24
|
+
next if renames.empty?
|
|
25
|
+
|
|
26
|
+
File.write(path, fixed) if write
|
|
27
|
+
renames.each { |from, to| changes << "#{path}: #{from} -> #{to}" }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
changes
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @return [Array(String, Array<Array(String, String)>)] the rewritten
|
|
34
|
+
# source and the renames applied to it.
|
|
35
|
+
def fix_source(source)
|
|
36
|
+
frontmatter, _format, = Andromeda::Frontmatter.split(source)
|
|
37
|
+
return [source, []] if frontmatter.nil?
|
|
38
|
+
|
|
39
|
+
renames = []
|
|
40
|
+
fixed_frontmatter = frontmatter.lines.map do |line|
|
|
41
|
+
match = KEY_LINE.match(line)
|
|
42
|
+
# Only top-level keys are renamed: an indented key belongs to a
|
|
43
|
+
# nested structure whose shape the schema does not describe.
|
|
44
|
+
next line unless match && match[1].empty?
|
|
45
|
+
|
|
46
|
+
key = match[3]
|
|
47
|
+
snake = snake_case(key)
|
|
48
|
+
next line if snake == key
|
|
49
|
+
|
|
50
|
+
renames << [key, snake]
|
|
51
|
+
line.sub(KEY_LINE) { "#{match[1]}#{match[2]}#{snake}#{match[2]}#{match[4]}" }
|
|
52
|
+
end.join
|
|
53
|
+
|
|
54
|
+
[source.sub(frontmatter, fixed_frontmatter), renames]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def snake_case(key)
|
|
58
|
+
key
|
|
59
|
+
.gsub("-", "_")
|
|
60
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
61
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
62
|
+
.downcase
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def content_files(entry_classes)
|
|
66
|
+
entry_classes.flat_map do |entry_class|
|
|
67
|
+
Dir.glob(File.join(entry_class.base_dir, entry_class.pattern)).sort
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|