andromeda_cms 0.1.0-arm64-darwin

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +30 -0
  3. data/CONTRIBUTING.md +47 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +276 -0
  6. data/lib/andromeda/assets.rb +128 -0
  7. data/lib/andromeda/check.rb +71 -0
  8. data/lib/andromeda/components/errors.rb +97 -0
  9. data/lib/andromeda/components/import_scanner.rb +72 -0
  10. data/lib/andromeda/components/static_expression.rb +79 -0
  11. data/lib/andromeda/components.rb +348 -0
  12. data/lib/andromeda/configuration.rb +91 -0
  13. data/lib/andromeda/entry.rb +294 -0
  14. data/lib/andromeda/errors.rb +173 -0
  15. data/lib/andromeda/fix.rb +71 -0
  16. data/lib/andromeda/frontmatter.rb +327 -0
  17. data/lib/andromeda/helpers.rb +84 -0
  18. data/lib/andromeda/id.rb +58 -0
  19. data/lib/andromeda/loader.rb +96 -0
  20. data/lib/andromeda/parser.rb +75 -0
  21. data/lib/andromeda/pipeline.rb +296 -0
  22. data/lib/andromeda/railtie.rb +47 -0
  23. data/lib/andromeda/registry.rb +127 -0
  24. data/lib/andromeda/relation.rb +125 -0
  25. data/lib/andromeda/renderer/code_highlighter.rb +53 -0
  26. data/lib/andromeda/renderer/errors.rb +52 -0
  27. data/lib/andromeda/renderer/literal_expression.rb +201 -0
  28. data/lib/andromeda/renderer.rb +411 -0
  29. data/lib/andromeda/schema.rb +383 -0
  30. data/lib/andromeda/slugger.rb +68 -0
  31. data/lib/andromeda/store.rb +286 -0
  32. data/lib/andromeda/tasks/andromeda.rake +56 -0
  33. data/lib/andromeda/version.rb +5 -0
  34. data/lib/andromeda_cms/3.2/andromeda_cms.bundle +0 -0
  35. data/lib/andromeda_cms/3.3/andromeda_cms.bundle +0 -0
  36. data/lib/andromeda_cms/3.4/andromeda_cms.bundle +0 -0
  37. data/lib/andromeda_cms/4.0/andromeda_cms.bundle +0 -0
  38. data/lib/andromeda_cms.rb +41 -0
  39. data/lib/generators/andromeda/collection/USAGE +25 -0
  40. data/lib/generators/andromeda/collection/collection_generator.rb +239 -0
  41. data/lib/generators/andromeda/component/USAGE +15 -0
  42. data/lib/generators/andromeda/component/component_generator.rb +75 -0
  43. data/lib/generators/andromeda/import_astro/USAGE +21 -0
  44. data/lib/generators/andromeda/import_astro/astro_schema_json.rb +80 -0
  45. data/lib/generators/andromeda/import_astro/balanced_scanner.rb +168 -0
  46. data/lib/generators/andromeda/import_astro/content_config_converter.rb +232 -0
  47. data/lib/generators/andromeda/import_astro/import_astro_generator.rb +313 -0
  48. data/lib/generators/andromeda/import_astro/mdx_content_scanner.rb +91 -0
  49. data/lib/generators/andromeda/import_astro/mdx_import_rewriter.rb +91 -0
  50. data/lib/generators/andromeda/install/USAGE +11 -0
  51. data/lib/generators/andromeda/install/install_generator.rb +77 -0
  52. data/lib/generators/andromeda/install/templates/initializer.rb +30 -0
  53. metadata +163 -0
@@ -0,0 +1,327 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "psych"
4
+ require "tomlrb"
5
+ require "date"
6
+ require "time"
7
+ require "stringio"
8
+
9
+ module Andromeda
10
+ # Splits and parses `---`/`+++` frontmatter, matching how Astro itself
11
+ # handles it: Astro strips frontmatter *before* handing the rest of the
12
+ # document to its Markdown engine (`packages/internal-helpers/src/frontmatter.ts`),
13
+ # rather than letting the Markdown parser recognize a frontmatter node. We
14
+ # do the same here so Sätteri (which has no built-in notion of `+++` TOML
15
+ # blocks) never has to see either fence.
16
+ module Frontmatter
17
+ # Recognized fences and the format they select, in the order Astro checks
18
+ # them (`getFrontmatterParser` in frontmatter.ts).
19
+ FENCES = { "---" => :yaml, "+++" => :toml }.freeze
20
+
21
+ # Optional BOM, then any number of purely-blank lines, before the fence
22
+ # itself. Mirrors the two branches of Astro's `frontmatterRE`
23
+ # (`^\uFEFF?` and `^\s*\n`) collapsed into one pass: BOM and leading blank
24
+ # lines are both tolerated, but only right at the top of the file -- once
25
+ # a non-blank line is seen, no `---`/`+++` after it is a fence.
26
+ LEADING_RE = /\A\uFEFF?(?:[ \t]*\r?\n)*/
27
+
28
+ # Psych resolves plain scalars by YAML 1.1 rules; js-yaml, which Astro
29
+ # uses, follows YAML 1.2's core schema. The two disagree on values that
30
+ # really do appear in hand-written frontmatter, and a silent disagreement
31
+ # is worse than an error: `time: 12:30` becoming the integer 45000 passes
32
+ # every schema check and then displays nonsense. Each rule below mirrors
33
+ # what js-yaml 4 does with the same plain scalar.
34
+ class ScalarScanner < Psych::ScalarScanner
35
+ # 1.1-only booleans; 1.2 keeps them as strings.
36
+ YAML_1_1_ONLY_BOOL = /\A(?:[Yy]es|YES|[Nn]o|NO|[Oo]n|ON|[Oo]ff|OFF|[Yy]|[Nn])\z/
37
+ # 1.1 base-60 (`1:30`) and comma-grouped (`1,000`) numbers; 1.2 has
38
+ # neither, so these stay strings.
39
+ YAML_1_1_ONLY_NUMBER = /\A[-+]?[0-9][0-9_]*(?:(?::[0-5]?[0-9])+|(?:,[0-9_]+)+)(?:\.[0-9_]*)?\z/
40
+ # 1.1 reads a leading zero as octal (`010` is 8); 1.2 reads it as decimal.
41
+ LEADING_ZERO_DECIMAL = /\A[-+]?0[0-9_]+\z/
42
+ # 1.2's explicit octal prefix, unknown to Psych.
43
+ OCTAL = /\A[-+]?0o[0-7_]+\z/
44
+ # 1.2 accepts an exponent without a decimal point (`1e3`); Psych does not.
45
+ EXPONENT_FLOAT = /\A[-+]?(?:[0-9][0-9_]*(?:\.[0-9_]*)?|\.[0-9_]+)[eE][-+]?[0-9]+\z/
46
+
47
+ def tokenize(string)
48
+ case string
49
+ when YAML_1_1_ONLY_BOOL, YAML_1_1_ONLY_NUMBER then string
50
+ when LEADING_ZERO_DECIMAL then Integer(string.delete("_"), 10)
51
+ when OCTAL then Integer(string.delete("_"))
52
+ when EXPONENT_FLOAT then Float(normalize_float(string.delete("_")))
53
+ else super
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ # Ruby's Float() rejects the `1.e3` and `.5e3` spellings YAML allows.
60
+ def normalize_float(string)
61
+ string.sub(/\.(?=[eE])/, ".0").sub(/\A([-+]?)\./, "\\10.")
62
+ end
63
+ end
64
+
65
+ # Restricts `!!` tags to Date/Time (matching js-yaml's default schema,
66
+ # which also has no notion of arbitrary Ruby classes) while still
67
+ # permitting the two classes plain `YYYY-MM-DD`/timestamp scalars resolve
68
+ # to, so unquoted dates round-trip instead of raising
69
+ # Psych::DisallowedClass.
70
+ class ClassLoader < Psych::ClassLoader::Restricted
71
+ def initialize
72
+ super(%w[Date Time], [])
73
+ end
74
+
75
+ public :load
76
+ end
77
+
78
+ # Plain (non-"safe") visitor so anchors/aliases resolve instead of
79
+ # raising -- js-yaml allows them by default, and copied Astro content may
80
+ # rely on them.
81
+ class ToRuby < Psych::Visitors::ToRuby
82
+ def initialize
83
+ class_loader = ClassLoader.new
84
+ super(ScalarScanner.new(class_loader), class_loader)
85
+ end
86
+ end
87
+
88
+ module_function
89
+
90
+ # Separates a leading frontmatter block from the rest of the document.
91
+ #
92
+ # @param source [String] raw file contents (may be CRLF, may start with a
93
+ # BOM).
94
+ # @return [Array(String, Symbol, String), Array(nil, nil, String)]
95
+ # `[frontmatter_text, format, body]` when a fence is found at the very
96
+ # start of the document, or `[nil, nil, source]` otherwise -- including
97
+ # when a `---`/`+++`-looking line is never closed, since that is
98
+ # indistinguishable from a document that simply starts with a thematic
99
+ # break (Astro's own regex only recognizes it as frontmatter once a
100
+ # matching closing fence is found too).
101
+ def split(source)
102
+ leading = LEADING_RE.match(source)[0]
103
+ rest = source[leading.length..]
104
+
105
+ fence, format = FENCES.find { |candidate, _| rest.start_with?(candidate) }
106
+ return [nil, nil, source] unless fence
107
+
108
+ after_fence = rest[fence.length..]
109
+ # The opening fence must be alone on its line (trailing spaces/tabs are
110
+ # tolerated, matching how editors sometimes leave them). Requiring this
111
+ # -- rather than letting the fence appear mid-line, which Astro's own
112
+ # regex technically permits -- is what keeps a value like
113
+ # `title: "a --- b"` from ever being mistaken for a fence.
114
+ open_eol = after_fence.match(/\A[ \t]*\r?\n/)
115
+ return [nil, nil, source] unless open_eol
116
+
117
+ content_start = leading.length + fence.length + open_eol[0].length
118
+ remainder = source[content_start..]
119
+
120
+ # Non-greedy scan for the first line consisting only of the *same*
121
+ # fence: this is what makes a `---` thematic break later in the body
122
+ # inert (it is never reached -- the frontmatter region has already
123
+ # closed by the first matching fence line) and lets an unterminated
124
+ # block fall through to "no frontmatter" below.
125
+ close_re = /^[ \t]*#{Regexp.escape(fence)}[ \t]*(?:\r?\n|\z)/
126
+ close_match = close_re.match(remainder)
127
+ return [nil, nil, source] unless close_match
128
+
129
+ frontmatter_text = remainder[0...close_match.begin(0)]
130
+ region_end = content_start + close_match.end(0)
131
+
132
+ # Replace the whole fenced region (both fence lines plus the content
133
+ # between them) with the same number of blank lines it occupied, so
134
+ # every line in `body` keeps its original line number -- callers hand
135
+ # `body` to Andromeda::Parser, and parse errors/positions must point at
136
+ # the real file line, not a line shifted by however long the
137
+ # frontmatter was.
138
+ lines_in_region = source[0...region_end].count("\n") - leading.count("\n")
139
+ body = leading + ("\n" * lines_in_region) + source[region_end..]
140
+
141
+ [frontmatter_text, format, body]
142
+ end
143
+
144
+ # Parses `source`'s frontmatter (if any) and returns `[data, body]`.
145
+ #
146
+ # `data` always has String keys exactly as written in the file -- no
147
+ # snake_case normalization here. Keys are normalized by rewriting files
148
+ # at import time (`andromeda:import_astro`); this layer only has to deal
149
+ # with whatever is currently on disk, camelCase leftovers included, which
150
+ # is what `non_snake_case_keys` is for.
151
+ #
152
+ # @param source [String] raw file contents.
153
+ # @param path [String, nil] attributed on a raised SyntaxError.
154
+ # @return [Array(Hash, String)]
155
+ # @raise [Andromeda::SyntaxError] on malformed YAML/TOML.
156
+ def parse(source, path: nil)
157
+ frontmatter_text, format, body = split(source)
158
+ return [{}, body] if format.nil?
159
+
160
+ # 1-based line number of the first line of `frontmatter_text` within
161
+ # the *original* `source` -- every error location below is this plus
162
+ # an offset relative to `frontmatter_text` alone, since Psych/tomlrb
163
+ # only ever see the extracted snippet.
164
+ content_start_line = LEADING_RE.match(source)[0].count("\n") + 2
165
+
166
+ data =
167
+ case format
168
+ when :yaml then parse_yaml(frontmatter_text, path: path, content_start_line: content_start_line)
169
+ when :toml then parse_toml(frontmatter_text, path: path, content_start_line: content_start_line)
170
+ end
171
+
172
+ [data || {}, body]
173
+ end
174
+
175
+ # @param data [Hash] frontmatter data as returned by `parse`.
176
+ # @return [Array<String>] top-level keys that are not snake_case (e.g.
177
+ # `pubDate`, `hero-image`), for `andromeda:check` to report.
178
+ def non_snake_case_keys(data)
179
+ data.keys.grep(String).reject { |key| snake_case?(key) }
180
+ end
181
+
182
+ # Lowercase ASCII letters/digits in underscore-separated segments, with
183
+ # no leading/trailing/doubled underscore. Deliberately ASCII-only: a
184
+ # non-ASCII key (or any key containing an uppercase or symbol character)
185
+ # is exactly the kind of thing `andromeda:check` should flag, since it
186
+ # cannot have come from our own snake_case rewriter.
187
+ SNAKE_CASE_RE = /\A[a-z0-9]+(?:_[a-z0-9]+)*\z/
188
+ private_constant :SNAKE_CASE_RE
189
+
190
+ def snake_case?(key)
191
+ key.match?(SNAKE_CASE_RE)
192
+ end
193
+ private_class_method :snake_case?
194
+
195
+ def parse_yaml(text, path:, content_start_line:)
196
+ return {} if text.strip.empty?
197
+
198
+ node = Psych.parse(text)
199
+ return {} if node.nil?
200
+
201
+ reject_alias_explosion!(node, path: path, content_start_line: content_start_line)
202
+ ToRuby.new.accept(node) || {}
203
+ rescue Psych::SyntaxError => e
204
+ # Psych's own #line/#column are 0-based and relative to `text` (the
205
+ # extracted frontmatter snippet), not the file Psych never saw -- shift
206
+ # them onto the real file so the raised error reads like every other
207
+ # Andromeda::SyntaxError (`path:line:column: message`).
208
+ raise syntax_error(psych_message(e), path: path, content_start_line: content_start_line,
209
+ relative_line: e.line, relative_column: e.column)
210
+ end
211
+ private_class_method :parse_yaml
212
+
213
+ def parse_toml(text, path:, content_start_line:)
214
+ return {} if text.strip.empty?
215
+
216
+ # Not calling the public Tomlrb.parse: it rescues Racc::ParseError
217
+ # itself and re-raises as Tomlrb::ParseError carrying only a message,
218
+ # which throws away the scanner's position along with it. Driving the
219
+ # scanner/parser directly keeps that position reachable below.
220
+ scanner = Tomlrb::Scanner.new(StringIO.new(text))
221
+ parser = Tomlrb::Parser.new(scanner)
222
+ normalize_toml_dates(parser.parse.output)
223
+ rescue Racc::ParseError, Tomlrb::ParseError, ArgumentError => e
224
+ relative_line, relative_column = tomlrb_error_location(scanner, text)
225
+ raise syntax_error(e.message, path: path, content_start_line: content_start_line,
226
+ relative_line: relative_line, relative_column: relative_column)
227
+ end
228
+ private_class_method :parse_toml
229
+
230
+ # tomlrb represents TOML's date-only and offset-less-datetime types with
231
+ # its own Tomlrb::LocalDate/LocalDateTime (only offset datetimes come
232
+ # back as a plain Time) -- neither is a Date or Time, so schema code
233
+ # written against `z.coerce.date()`-style expectations would have
234
+ # to special-case tomlrb just to call `.year` on them. Walk the parsed
235
+ # tree once and swap them for the real thing a local date/date-time
236
+ # naturally maps to.
237
+ def normalize_toml_dates(value)
238
+ case value
239
+ when Hash
240
+ value.transform_values { |v| normalize_toml_dates(v) }
241
+ when Array
242
+ value.map { |v| normalize_toml_dates(v) }
243
+ when Tomlrb::LocalDate
244
+ Date.new(value.year, value.month, value.day)
245
+ when Tomlrb::LocalDateTime
246
+ value.to_time
247
+ else
248
+ value
249
+ end
250
+ end
251
+ private_class_method :normalize_toml_dates
252
+
253
+ # tomlrb's generated (Racc) parser tracks no token-start positions at
254
+ # all, only the underlying StringScanner's current byte offset -- which,
255
+ # by the time the parser rejects a token, already sits *after* that
256
+ # token. So the line this resolves to is exact (the offending token is
257
+ # still on it), but the column is best-effort: it points just past the
258
+ # token rather than at its start. That is enough to satisfy "readable
259
+ # without context" without pretending to a precision tomlrb doesn't have.
260
+ def tomlrb_error_location(scanner, text)
261
+ pos = scanner.instance_variable_get(:@ss)&.pos
262
+ return [0, 0] unless pos
263
+
264
+ head = text.byteslice(0, pos) || text
265
+ last_newline = head.rindex("\n")
266
+ line = head.count("\n")
267
+ column = last_newline ? pos - last_newline - 1 : pos
268
+ [line, column]
269
+ end
270
+ private_class_method :tomlrb_error_location
271
+
272
+ # Frontmatter may use anchors and aliases, but nested aliases multiply: a
273
+ # few hundred bytes can describe hundreds of millions of values
274
+ # ("billion laughs"). Psych shares the repeated objects, so parsing is
275
+ # cheap; writing the entry to JSON is where every copy gets materialized
276
+ # and the build runs out of memory. Counting the expanded size on the
277
+ # node tree -- where each anchor's size is computed once -- catches that
278
+ # before anything is expanded. No real frontmatter comes close.
279
+ MAX_EXPANDED_NODES = 100_000
280
+ private_constant :MAX_EXPANDED_NODES
281
+
282
+ def reject_alias_explosion!(document, path:, content_start_line:)
283
+ expanded_size(document, {}) do |node|
284
+ raise syntax_error("aliases expand to more than #{MAX_EXPANDED_NODES} values",
285
+ path: path, content_start_line: content_start_line,
286
+ relative_line: node.start_line, relative_column: node.start_column)
287
+ end
288
+ end
289
+ private_class_method :reject_alias_explosion!
290
+
291
+ # Number of values `node` stands for once every alias is expanded.
292
+ # `sizes` remembers each anchor's total, so an alias costs a lookup
293
+ # rather than a second walk. Yields the first node over the limit.
294
+ def expanded_size(node, sizes, &over_limit)
295
+ size =
296
+ if node.is_a?(Psych::Nodes::Alias)
297
+ sizes.fetch(node.anchor, 1)
298
+ else
299
+ node.children.to_a.sum(1) { |child| expanded_size(child, sizes, &over_limit) }
300
+ end
301
+ sizes[node.anchor] = size if !node.is_a?(Psych::Nodes::Alias) && node.respond_to?(:anchor) && node.anchor
302
+ yield node if size > MAX_EXPANDED_NODES
303
+
304
+ size
305
+ end
306
+ private_class_method :expanded_size
307
+
308
+ def syntax_error(message, path:, content_start_line:, relative_line:, relative_column:)
309
+ Andromeda::SyntaxError.new(
310
+ message,
311
+ path: path,
312
+ line: content_start_line + relative_line,
313
+ column: relative_column + 1
314
+ )
315
+ end
316
+ private_class_method :syntax_error
317
+
318
+ # Psych's message is prefixed with "(<unknown>): " when it has no
319
+ # filename of its own (we never give it one, since it only ever sees the
320
+ # extracted snippet) -- strip that so it doesn't read like a second,
321
+ # bogus path segment next to the one Andromeda::SyntaxError adds.
322
+ def psych_message(error)
323
+ error.message.sub(/\A\(<unknown>\):\s*/, "")
324
+ end
325
+ private_class_method :psych_message
326
+ end
327
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Andromeda
4
+ # View helpers mixed into ActionView by the Railtie. Everything here is a
5
+ # thin presentation layer over data the build already produced: no parsing
6
+ # or rendering happens while serving a request.
7
+ module Helpers
8
+ # Outputs an entry's converted body. Marked html_safe because the build
9
+ # produced this markup from content the application's own authors wrote,
10
+ # the same trust model Astro applies to `.md`/`.mdx` (see 13_security).
11
+ #
12
+ # @param entry [Andromeda::Entry]
13
+ # @return [ActiveSupport::SafeBuffer]
14
+ def andromeda_content(entry)
15
+ Andromeda::Assets.resolve(entry.html).html_safe # rubocop:disable Rails/OutputSafety
16
+ end
17
+
18
+ # @param image [Andromeda::Image, nil] an `image` attribute's value.
19
+ # @return [String, nil] its URL, or nil when the attribute is unset.
20
+ def andromeda_image_url(image)
21
+ return nil if image.nil?
22
+
23
+ Andromeda::Assets.resolve(image.asset || image.relative_path)
24
+ end
25
+
26
+ # Renders a nested table of contents from `entry.headings`.
27
+ #
28
+ # `min`/`max` default to h2..h3 because a page's h1 is usually the entry
29
+ # title rendered by the layout, not part of the body outline.
30
+ #
31
+ # @param entry [Andromeda::Entry]
32
+ # @param min [Integer] shallowest heading level to include.
33
+ # @param max [Integer] deepest heading level to include.
34
+ # @return [ActiveSupport::SafeBuffer] an empty string when nothing matches.
35
+ def andromeda_toc(entry, min: 2, max: 3)
36
+ headings = entry.headings.map { |heading| heading.transform_keys(&:to_sym) }
37
+ .select { |heading| heading[:depth].between?(min, max) }
38
+ return "".html_safe if headings.empty?
39
+
40
+ andromeda_toc_list(headings, min)
41
+ end
42
+
43
+ # Emits the meta tags an entry can fill in on its own. Anything needing
44
+ # site-wide knowledge (og:image defaults, canonical host) belongs in the
45
+ # application layout, which is why only these three are produced here.
46
+ #
47
+ # @param entry [Andromeda::Entry]
48
+ # @param url [String, nil] canonical URL, when the caller knows it.
49
+ # @return [ActiveSupport::SafeBuffer]
50
+ def andromeda_meta_tags(entry, url: nil)
51
+ data = entry.data
52
+ tags = []
53
+ tags << tag.title(data[:title]) if data[:title]
54
+ tags << tag.meta(name: "description", content: data[:description]) if data[:description]
55
+ tags << tag.link(rel: "canonical", href: url) if url
56
+ safe_join(tags, "\n")
57
+ end
58
+
59
+ private
60
+
61
+ # Builds one `<ul>` per heading level, descending only when the next
62
+ # heading is deeper, so the markup mirrors the document outline. A nested
63
+ # list belongs inside its parent `<li>`, which is why items are collected
64
+ # as inner HTML and wrapped only once their children are known.
65
+ def andromeda_toc_list(headings, depth)
66
+ items = []
67
+
68
+ while (heading = headings.first)
69
+ break if heading[:depth] < depth
70
+
71
+ if heading[:depth] > depth
72
+ nested = andromeda_toc_list(headings, heading[:depth])
73
+ items << (items.pop || "".html_safe) + nested
74
+ next
75
+ end
76
+
77
+ headings.shift
78
+ items << link_to(heading[:text], "##{heading[:slug]}")
79
+ end
80
+
81
+ tag.ul(safe_join(items.map { |item| tag.li(item) }, "\n"))
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Andromeda
4
+ # Entry id generation, faithful to Astro's `glob()` loader:
5
+ #
6
+ # 1. A frontmatter `slug:` wins verbatim -- it is used as-is, not
7
+ # re-slugified, so an author can deliberately keep an id that is not
8
+ # slug-shaped (spaces, uppercase, whatever).
9
+ # 2. Otherwise the extension is stripped from the path (relative to the
10
+ # collection's `base`), each `/`-separated segment is run through
11
+ # github-slugger, and the segments are rejoined with `/`.
12
+ # 3. A trailing `index` segment is dropped, so directory-style entries
13
+ # (`posts/hello/index.mdx`) collapse onto their directory
14
+ # (`posts/hello`).
15
+ #
16
+ # Duplicate-id detection is deliberately NOT this module's job -- it needs
17
+ # to see every file in a collection at once, which only Loader can do.
18
+ module Id
19
+ module_function
20
+
21
+ # @param relative_path [String] entry path relative to the collection's
22
+ # `base`, extension included (e.g. "Guides/Getting Started.md").
23
+ # @param slug [Object, nil] the raw frontmatter `slug:` value, if any
24
+ # (String-keyed, straight out of Andromeda::Frontmatter.parse -- not
25
+ # validated Schema output, since `slug` need not be a declared
26
+ # attribute).
27
+ # @return [String]
28
+ def generate(relative_path, slug: nil)
29
+ return slug.to_s if slug_override?(slug)
30
+
31
+ without_extension = relative_path.to_s.sub(/\.[^.\/\\]+\z/, "")
32
+ segments = without_extension.split(%r{[/\\]}).reject(&:empty?)
33
+
34
+ # A fresh Slugger per segment: its dedup counters (`-1`, `-2`, ...)
35
+ # exist to disambiguate headings repeated within one document, not
36
+ # path segments -- sharing one instance across an entire directory
37
+ # tree would silently rename a second `guides/guides.md`-shaped
38
+ # collision instead of letting the Loader's duplicate-id check catch
39
+ # it as the error it actually is.
40
+ slugged = segments.map { |segment| Andromeda::Slugger.new.slug(segment) }
41
+ # Astro strips a trailing `index` only when a directory precedes it
42
+ # (its rule is `replace(/\/index$/, "")`), so a collection's own
43
+ # `index.md` keeps the id "index" instead of collapsing to "".
44
+ slugged.pop if slugged.size > 1 && slugged.last == "index"
45
+ slugged.join("/")
46
+ end
47
+
48
+ # Astro tests `if (data.slug)`, so every JavaScript-falsy value -- not
49
+ # just a missing key -- falls back to the path. A blanked-out `slug: ""`
50
+ # left over from a template must not become an empty id.
51
+ def slug_override?(slug)
52
+ return false if slug.nil? || slug == false || slug == ""
53
+ return false if slug.is_a?(Numeric) && (slug.zero? || (slug.is_a?(Float) && slug.nan?))
54
+
55
+ true
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Andromeda
6
+ # The v0 File loader: expands `base`/`pattern` into a list
7
+ # of files, splits and validates each one's frontmatter, computes its id,
8
+ # and builds `entry_class` instances. ActiveRecord/HTTP loaders are
9
+ # out of scope for v0 -- content is file-only for now, so this is the
10
+ # only implementation; the shape below (`#load` returning entries,
11
+ # everything else private) is what a second loader would need to match.
12
+ #
13
+ # Every problem across every file is collected into one
14
+ # `Andromeda::LoaderError` instead of raising on the first bad file --
15
+ # same "report everything in one pass" choice `Schema#validate` makes, for
16
+ # the same reason: nobody wants to fix-and-rerun one file at a time.
17
+ class Loader
18
+ # @param entry_class [Class] an Andromeda::Entry subclass; used both to
19
+ # construct instances and to read its declared `schema`.
20
+ # @param base [String] absolute path to the collection's content root.
21
+ # @param pattern [String] a Dir.glob pattern, relative to `base`.
22
+ # @param schema [Andromeda::Schema]
23
+ def initialize(entry_class:, base:, pattern:, schema:)
24
+ @entry_class = entry_class
25
+ @base = base
26
+ @pattern = pattern
27
+ @schema = schema
28
+ end
29
+
30
+ # @return [Array<Andromeda::Entry>]
31
+ # @raise [Andromeda::LoaderError] if any file fails to validate/parse,
32
+ # or if two files resolve to the same id.
33
+ def load
34
+ root = File.expand_path(@base)
35
+ paths = Dir.glob(File.join(root, @pattern)).select { |path| File.file?(path) }.sort
36
+
37
+ messages = []
38
+ seen_ids = {}
39
+ entries = []
40
+
41
+ paths.each do |path|
42
+ entry, error = build_entry(path, root)
43
+ if error
44
+ messages << error
45
+ next
46
+ end
47
+
48
+ if seen_ids.key?(entry.id)
49
+ messages << "#{path}: duplicate id #{entry.id.inspect} (already used by #{seen_ids[entry.id]})"
50
+ next
51
+ end
52
+
53
+ seen_ids[entry.id] = path
54
+ entries << entry
55
+ end
56
+
57
+ raise Andromeda::LoaderError, messages unless messages.empty?
58
+
59
+ entries
60
+ end
61
+
62
+ private
63
+
64
+ # @return [Array(Andromeda::Entry, nil), Array(nil, String)]
65
+ def build_entry(path, root)
66
+ source = File.read(path)
67
+ frontmatter, body = Andromeda::Frontmatter.parse(source, path: path)
68
+
69
+ result = @schema.validate(frontmatter, path: path, entry_path: path, content_root: root)
70
+ unless result.valid?
71
+ return [nil, result.problems.map { |problem| "#{path}: #{problem.message}" }.join("\n")]
72
+ end
73
+
74
+ relative_path = path.delete_prefix(root + File::SEPARATOR)
75
+ id = Andromeda::Id.generate(relative_path, slug: frontmatter["slug"])
76
+
77
+ entry = @entry_class.new(
78
+ id: id,
79
+ collection: @entry_class.collection_name,
80
+ data: result.data,
81
+ body: body,
82
+ file_path: path,
83
+ # SHA-256 of the whole raw file (frontmatter included), not just the
84
+ # body: a frontmatter-only edit (e.g. fixing a typo in `title`)
85
+ # should invalidate any cache keyed on this just as much as a body
86
+ # edit would -- `digest` is a cache key for the
87
+ # content, and the frontmatter is as much "the content" as the
88
+ # body is.
89
+ digest: Digest::SHA256.hexdigest(source)
90
+ )
91
+ [entry, nil]
92
+ rescue Andromeda::SyntaxError => e
93
+ [nil, e.message]
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Andromeda
6
+ # Thin Ruby wrapper around the native Sätteri bridge (`Parser.native_parse`,
7
+ # defined in ext/andromeda_cms/src/lib.rs).
8
+ #
9
+ # The native method returns mdast as a JSON string rather than nested Ruby
10
+ # objects (building `Hash`/`Array` node-by-node through the Ruby C API is
11
+ # the slow part, not parsing — see ext/andromeda_cms/src/lib.rs), so this
12
+ # wrapper's job is just: parse that JSON, and turn native errors into
13
+ # `Andromeda::SyntaxError`/`Andromeda::ParserError` instances that carry the
14
+ # caller's `path` (the native layer only knows about `source`, never which
15
+ # file it came from).
16
+ module Parser
17
+ MARKDOWN_EXTENSIONS = [".md", ".markdown"].freeze
18
+ MDX_EXTENSIONS = [".mdx"].freeze
19
+
20
+ module_function
21
+
22
+ # Parses Markdown or MDX `source` into an mdast tree.
23
+ #
24
+ # Returns a Hash with **symbol keys** (`type:`, `children:`, `position:`,
25
+ # ...), chosen because callers pattern-match on `node[:type]` far more
26
+ # than they serialize the tree back out, and symbol keys read like the
27
+ # mdast-spec field names they mirror.
28
+ #
29
+ # @param source [String] Markdown or MDX source text.
30
+ # @param mdx [Boolean] parse MDX syntax (JSX, expressions, ESM imports)?
31
+ # @param path [String, nil] file path to attribute errors to; purely
32
+ # cosmetic (only used in raised error messages), never read from disk.
33
+ # @raise [Andromeda::SyntaxError] if `source` cannot be parsed.
34
+ # @raise [Andromeda::ParserError] if the native parser panics.
35
+ def parse(source, mdx: false, path: nil)
36
+ # Deeply nested Markdown (blockquotes, lists) produces a proportionally
37
+ # deep JSON tree; JSON's default max_nesting (100) exists to guard
38
+ # against adversarial input in the *decoder*, but the tree is already
39
+ # fully materialized in memory by the time it reaches here, so there is
40
+ # nothing left to protect against by also capping the parse depth.
41
+ JSON.parse(native_parse(source, mdx), symbolize_names: true, max_nesting: false)
42
+ rescue Andromeda::SyntaxError => e
43
+ raise with_path(e, path)
44
+ rescue Andromeda::ParserError => e
45
+ raise with_path(e, path)
46
+ rescue SystemStackError
47
+ # The native layer bounds nesting, but a thread with an unusually small
48
+ # stack could still run out first. A build log naming the file is
49
+ # worth more than a bare "stack level too deep".
50
+ raise with_path(Andromeda::ParserError.new("content is nested too deeply to parse"), path)
51
+ end
52
+
53
+ # @param path [String, Pathname] a content file's path.
54
+ # @return [Boolean] whether `parse` should be called with `mdx: false`.
55
+ def markdown?(path)
56
+ MARKDOWN_EXTENSIONS.include?(File.extname(path.to_s).downcase)
57
+ end
58
+
59
+ # @param path [String, Pathname] a content file's path.
60
+ # @return [Boolean] whether `parse` should be called with `mdx: true`.
61
+ def mdx?(path)
62
+ MDX_EXTENSIONS.include?(File.extname(path.to_s).downcase)
63
+ end
64
+
65
+ # Re-raises a native error with `path` attached, so editors/CI logs read
66
+ # `app/content/blog/x.mdx:12:3: ...` instead of just `12:3: ...`.
67
+ def with_path(error, path)
68
+ return error if path.nil?
69
+ return error.at(path) if error.respond_to?(:at)
70
+
71
+ error.class.new("#{path}: #{error.message}")
72
+ end
73
+ private_class_method :with_path
74
+ end
75
+ end