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,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
|
data/lib/andromeda/id.rb
ADDED
|
@@ -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
|