andromeda_cms 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +30 -0
- data/CONTRIBUTING.md +47 -0
- data/Cargo.lock +1182 -0
- data/Cargo.toml +12 -0
- data/LICENSE.txt +21 -0
- data/README.md +276 -0
- data/ext/andromeda_cms/Cargo.toml +18 -0
- data/ext/andromeda_cms/extconf.rb +6 -0
- data/ext/andromeda_cms/src/errors.rs +55 -0
- data/ext/andromeda_cms/src/lib.rs +143 -0
- data/ext/andromeda_cms/src/nodes.rs +400 -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.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 +161 -0
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
//! Walks a Sätteri `Arena<Mdast>` and renders it as mdast-spec-shaped JSON.
|
|
2
|
+
//!
|
|
3
|
+
//! This hand-written decode (rather than `#[derive(Serialize)]`) is
|
|
4
|
+
//! necessary: Sätteri stores node-specific fields as a binary
|
|
5
|
+
//! `#[repr(C)]` blob (`type_data`) rather than an idiomatic Rust enum, so
|
|
6
|
+
//! each node type needs its own `XxxData::from_bytes` call
|
|
7
|
+
//! (`satteri-ast`'s design targets its own napi/TS layer, not
|
|
8
|
+
//! third-party Rust embedders).
|
|
9
|
+
//!
|
|
10
|
+
//! Every node here also carries a unist `position`
|
|
11
|
+
//! (start/end line, column, offset). The gem needs
|
|
12
|
+
//! line numbers to build `path:line:column: message` errors, so positions
|
|
13
|
+
//! are not optional here.
|
|
14
|
+
|
|
15
|
+
use satteri_arena::{decode_string_ref_data, Arena, Mdast, StringRef};
|
|
16
|
+
use satteri_ast::mdast::codec::*;
|
|
17
|
+
use satteri_ast::mdast::MdastNodeType;
|
|
18
|
+
use serde_json::{json, Map, Value};
|
|
19
|
+
|
|
20
|
+
fn get_str(arena: &Arena<Mdast>, sr: StringRef) -> String {
|
|
21
|
+
arena.get_str(sr).to_string()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
fn opt_str(arena: &Arena<Mdast>, sr: StringRef) -> Value {
|
|
25
|
+
if sr.is_empty() {
|
|
26
|
+
Value::Null
|
|
27
|
+
} else {
|
|
28
|
+
Value::String(get_str(arena, sr))
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/// unist `Position`: 1-based line/column (as Sätteri's `LineIndex` computes
|
|
33
|
+
/// them — UTF-16-code-unit columns, matching what remark/unist report) and a
|
|
34
|
+
/// byte offset (Sätteri does not track a separate UTF-16 offset per node, so
|
|
35
|
+
/// `offset` is a byte index here; this is the one field that does not match
|
|
36
|
+
/// mdast-util-from-markdown exactly, and only matters for tooling that
|
|
37
|
+
/// slices `source[start.offset...end.offset]` on non-ASCII input).
|
|
38
|
+
fn position(arena: &Arena<Mdast>, id: u32) -> Value {
|
|
39
|
+
let node = arena.get_node(id);
|
|
40
|
+
json!({
|
|
41
|
+
"start": {
|
|
42
|
+
"line": node.start_line,
|
|
43
|
+
"column": node.start_column,
|
|
44
|
+
"offset": node.start_offset,
|
|
45
|
+
},
|
|
46
|
+
"end": {
|
|
47
|
+
"line": node.end_line,
|
|
48
|
+
"column": node.end_column,
|
|
49
|
+
"offset": node.end_offset,
|
|
50
|
+
},
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/// Whether mdast-util-from-markdown would emit a `children` array for this
|
|
55
|
+
/// node type, even when empty (a unist `Parent`) as opposed to leaving it
|
|
56
|
+
/// out entirely (a unist `Literal`/leaf). Kept as an explicit allow-list
|
|
57
|
+
/// rather than "non-empty children" so an empty paragraph/root/etc still
|
|
58
|
+
/// round-trips as `children: []` instead of silently losing the field.
|
|
59
|
+
fn is_parent(nt: MdastNodeType) -> bool {
|
|
60
|
+
matches!(
|
|
61
|
+
nt,
|
|
62
|
+
MdastNodeType::Root
|
|
63
|
+
| MdastNodeType::Paragraph
|
|
64
|
+
| MdastNodeType::Heading
|
|
65
|
+
| MdastNodeType::Emphasis
|
|
66
|
+
| MdastNodeType::Strong
|
|
67
|
+
| MdastNodeType::Delete
|
|
68
|
+
| MdastNodeType::Blockquote
|
|
69
|
+
| MdastNodeType::List
|
|
70
|
+
| MdastNodeType::ListItem
|
|
71
|
+
| MdastNodeType::Link
|
|
72
|
+
| MdastNodeType::LinkReference
|
|
73
|
+
| MdastNodeType::Table
|
|
74
|
+
| MdastNodeType::TableRow
|
|
75
|
+
| MdastNodeType::TableCell
|
|
76
|
+
| MdastNodeType::FootnoteDefinition
|
|
77
|
+
| MdastNodeType::Superscript
|
|
78
|
+
| MdastNodeType::Subscript
|
|
79
|
+
| MdastNodeType::ContainerDirective
|
|
80
|
+
| MdastNodeType::LeafDirective
|
|
81
|
+
| MdastNodeType::TextDirective
|
|
82
|
+
| MdastNodeType::DescriptionList
|
|
83
|
+
| MdastNodeType::DescriptionTerm
|
|
84
|
+
| MdastNodeType::DescriptionDetails
|
|
85
|
+
| MdastNodeType::MdxJsxFlowElement
|
|
86
|
+
| MdastNodeType::MdxJsxTextElement
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/// Best-effort decode of a node's `type_data` into JSON fields, keyed by
|
|
91
|
+
/// mdast-spec field names. Node types not covered here (e.g. `thematicBreak`,
|
|
92
|
+
/// `break`) have no extra fields in the spec either, so they fall through to
|
|
93
|
+
/// `{}`.
|
|
94
|
+
fn type_fields(arena: &Arena<Mdast>, id: u32, nt: MdastNodeType) -> Value {
|
|
95
|
+
let data = arena.get_type_data(id);
|
|
96
|
+
match nt {
|
|
97
|
+
MdastNodeType::Text
|
|
98
|
+
| MdastNodeType::InlineCode
|
|
99
|
+
| MdastNodeType::Html
|
|
100
|
+
| MdastNodeType::Yaml
|
|
101
|
+
| MdastNodeType::Toml
|
|
102
|
+
| MdastNodeType::MdxFlowExpression
|
|
103
|
+
| MdastNodeType::MdxTextExpression
|
|
104
|
+
| MdastNodeType::MdxjsEsm => {
|
|
105
|
+
if data.is_empty() {
|
|
106
|
+
json!({ "value": "" })
|
|
107
|
+
} else {
|
|
108
|
+
let sr = decode_string_ref_data(data);
|
|
109
|
+
json!({ "value": get_str(arena, sr) })
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
MdastNodeType::Math | MdastNodeType::InlineMath => {
|
|
113
|
+
if data.len() >= 16 {
|
|
114
|
+
let d = MathData::from_bytes(data);
|
|
115
|
+
json!({
|
|
116
|
+
"meta": opt_str(arena, d.meta),
|
|
117
|
+
"value": get_str(arena, d.value),
|
|
118
|
+
})
|
|
119
|
+
} else {
|
|
120
|
+
json!({})
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
MdastNodeType::Heading => {
|
|
124
|
+
if !data.is_empty() {
|
|
125
|
+
json!({ "depth": data[0] })
|
|
126
|
+
} else {
|
|
127
|
+
json!({})
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
MdastNodeType::Link => {
|
|
131
|
+
if !data.is_empty() {
|
|
132
|
+
let d = LinkData::from_bytes(data);
|
|
133
|
+
json!({
|
|
134
|
+
"url": get_str(arena, d.url),
|
|
135
|
+
"title": opt_str(arena, d.title),
|
|
136
|
+
})
|
|
137
|
+
} else {
|
|
138
|
+
json!({})
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
MdastNodeType::Image => {
|
|
142
|
+
if !data.is_empty() {
|
|
143
|
+
let d = ImageData::from_bytes(data);
|
|
144
|
+
json!({
|
|
145
|
+
"url": get_str(arena, d.url),
|
|
146
|
+
"alt": opt_str(arena, d.alt),
|
|
147
|
+
"title": opt_str(arena, d.title),
|
|
148
|
+
})
|
|
149
|
+
} else {
|
|
150
|
+
json!({})
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
MdastNodeType::Code => {
|
|
154
|
+
if !data.is_empty() {
|
|
155
|
+
let d = CodeData::from_bytes(data);
|
|
156
|
+
json!({
|
|
157
|
+
"lang": opt_str(arena, d.lang),
|
|
158
|
+
"meta": opt_str(arena, d.meta),
|
|
159
|
+
"value": get_str(arena, d.value),
|
|
160
|
+
})
|
|
161
|
+
} else {
|
|
162
|
+
json!({})
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
MdastNodeType::List => {
|
|
166
|
+
if !data.is_empty() {
|
|
167
|
+
let d = ListData::from_bytes(data);
|
|
168
|
+
json!({
|
|
169
|
+
"ordered": d.ordered,
|
|
170
|
+
"start": if d.ordered { Value::from(d.start) } else { Value::Null },
|
|
171
|
+
"spread": d.spread,
|
|
172
|
+
})
|
|
173
|
+
} else {
|
|
174
|
+
json!({})
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
MdastNodeType::ListItem => {
|
|
178
|
+
if !data.is_empty() {
|
|
179
|
+
let d = ListItemData::from_bytes(data);
|
|
180
|
+
json!({
|
|
181
|
+
"checked": match d.checked { 0 => Value::Bool(false), 1 => Value::Bool(true), _ => Value::Null },
|
|
182
|
+
"spread": d.spread,
|
|
183
|
+
})
|
|
184
|
+
} else {
|
|
185
|
+
json!({})
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
MdastNodeType::DescriptionDetails => {
|
|
189
|
+
if !data.is_empty() {
|
|
190
|
+
let d = DescriptionDetailsData::from_bytes(data);
|
|
191
|
+
json!({ "spread": d.spread })
|
|
192
|
+
} else {
|
|
193
|
+
json!({})
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
MdastNodeType::LinkReference
|
|
197
|
+
| MdastNodeType::ImageReference
|
|
198
|
+
| MdastNodeType::FootnoteReference => {
|
|
199
|
+
if !data.is_empty() {
|
|
200
|
+
let d = ReferenceData::from_bytes(data);
|
|
201
|
+
let kind = match d.reference_kind {
|
|
202
|
+
0 => "shortcut",
|
|
203
|
+
1 => "collapsed",
|
|
204
|
+
_ => "full",
|
|
205
|
+
};
|
|
206
|
+
let mut fields = Map::new();
|
|
207
|
+
fields.insert(
|
|
208
|
+
"identifier".into(),
|
|
209
|
+
Value::String(get_str(arena, d.identifier)),
|
|
210
|
+
);
|
|
211
|
+
fields.insert("label".into(), opt_str(arena, d.label));
|
|
212
|
+
// mdast spec: only link/imageReference carry referenceType,
|
|
213
|
+
// not footnoteReference.
|
|
214
|
+
if nt != MdastNodeType::FootnoteReference {
|
|
215
|
+
fields.insert("referenceType".into(), Value::String(kind.into()));
|
|
216
|
+
}
|
|
217
|
+
// imageReference alone also carries `alt`, stored as a trailing
|
|
218
|
+
// StringRef appended after the shared ReferenceData header (see
|
|
219
|
+
// satteri_ast::mdast::codec::encode_image_reference_data).
|
|
220
|
+
if nt == MdastNodeType::ImageReference {
|
|
221
|
+
let alt = decode_image_reference_alt(data);
|
|
222
|
+
fields.insert("alt".into(), opt_str(arena, alt));
|
|
223
|
+
}
|
|
224
|
+
Value::Object(fields)
|
|
225
|
+
} else {
|
|
226
|
+
json!({})
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
MdastNodeType::FootnoteDefinition => {
|
|
230
|
+
if !data.is_empty() {
|
|
231
|
+
let d = FootnoteDefinitionData::from_bytes(data);
|
|
232
|
+
json!({
|
|
233
|
+
"identifier": get_str(arena, d.identifier),
|
|
234
|
+
"label": opt_str(arena, d.label),
|
|
235
|
+
})
|
|
236
|
+
} else {
|
|
237
|
+
json!({})
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
MdastNodeType::Definition => {
|
|
241
|
+
if !data.is_empty() {
|
|
242
|
+
let d = DefinitionData::from_bytes(data);
|
|
243
|
+
json!({
|
|
244
|
+
"url": get_str(arena, d.url),
|
|
245
|
+
"title": opt_str(arena, d.title),
|
|
246
|
+
"identifier": get_str(arena, d.identifier),
|
|
247
|
+
"label": opt_str(arena, d.label),
|
|
248
|
+
})
|
|
249
|
+
} else {
|
|
250
|
+
json!({})
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
MdastNodeType::Table => {
|
|
254
|
+
// TableData header (align_count: u32) followed by align_count
|
|
255
|
+
// ColumnAlign bytes (see satteri_ast::mdast::codec::TableData).
|
|
256
|
+
if data.len() >= 4 {
|
|
257
|
+
let count = u32::from_le_bytes(data[0..4].try_into().unwrap()) as usize;
|
|
258
|
+
let mut aligns = vec![];
|
|
259
|
+
for i in 0..count {
|
|
260
|
+
let b = *data.get(4 + i).unwrap_or(&0);
|
|
261
|
+
aligns.push(match b {
|
|
262
|
+
1 => Value::String("left".into()),
|
|
263
|
+
2 => Value::String("right".into()),
|
|
264
|
+
3 => Value::String("center".into()),
|
|
265
|
+
_ => Value::Null,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
json!({ "align": aligns })
|
|
269
|
+
} else {
|
|
270
|
+
json!({})
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
MdastNodeType::ContainerDirective
|
|
274
|
+
| MdastNodeType::LeafDirective
|
|
275
|
+
| MdastNodeType::TextDirective => {
|
|
276
|
+
if data.len() >= 12 {
|
|
277
|
+
let name = decode_directive_name(data);
|
|
278
|
+
let attr_count = decode_directive_attr_count(data);
|
|
279
|
+
let mut attrs = Map::new();
|
|
280
|
+
for i in 0..attr_count {
|
|
281
|
+
let (key, value) = decode_directive_attr(data, i);
|
|
282
|
+
attrs.insert(get_str(arena, key), Value::String(get_str(arena, value)));
|
|
283
|
+
}
|
|
284
|
+
json!({
|
|
285
|
+
"name": get_str(arena, name),
|
|
286
|
+
"attributes": attrs,
|
|
287
|
+
})
|
|
288
|
+
} else {
|
|
289
|
+
json!({})
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
MdastNodeType::MdxJsxFlowElement | MdastNodeType::MdxJsxTextElement => {
|
|
293
|
+
if data.len() >= 16 {
|
|
294
|
+
let name_sr = decode_mdx_jsx_element_name(data);
|
|
295
|
+
let attr_count = decode_mdx_jsx_attr_count(data);
|
|
296
|
+
let name = if name_sr.is_empty() {
|
|
297
|
+
Value::Null // fragment (<>...</>)
|
|
298
|
+
} else {
|
|
299
|
+
Value::String(get_str(arena, name_sr))
|
|
300
|
+
};
|
|
301
|
+
let mut attrs = vec![];
|
|
302
|
+
for i in 0..attr_count {
|
|
303
|
+
let (kind, attr_name, attr_value) = decode_mdx_jsx_attr(data, i);
|
|
304
|
+
if kind == 3 {
|
|
305
|
+
// spread ({...expr}): no name, value is the raw
|
|
306
|
+
// expression text.
|
|
307
|
+
attrs.push(json!({
|
|
308
|
+
"type": "mdxJsxExpressionAttribute",
|
|
309
|
+
"value": get_str(arena, attr_value),
|
|
310
|
+
}));
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
let value = match kind {
|
|
314
|
+
0 => Value::Null, // boolean prop
|
|
315
|
+
1 => Value::String(get_str(arena, attr_value)), // literal
|
|
316
|
+
_ => {
|
|
317
|
+
json!({ "type": "mdxJsxAttributeValueExpression", "value": get_str(arena, attr_value) })
|
|
318
|
+
} // expression prop
|
|
319
|
+
};
|
|
320
|
+
attrs.push(json!({
|
|
321
|
+
"type": "mdxJsxAttribute",
|
|
322
|
+
"name": get_str(arena, attr_name),
|
|
323
|
+
"value": value,
|
|
324
|
+
}));
|
|
325
|
+
}
|
|
326
|
+
json!({ "name": name, "attributes": attrs })
|
|
327
|
+
} else {
|
|
328
|
+
json!({ "name": Value::Null, "attributes": Value::Array(vec![]) })
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
MdastNodeType::Custom => {
|
|
332
|
+
if data.len() >= 16 {
|
|
333
|
+
let name = StringRef::from_bytes(&data[0..8]);
|
|
334
|
+
let value = StringRef::from_bytes(&data[8..16]);
|
|
335
|
+
json!({
|
|
336
|
+
"customType": get_str(arena, name),
|
|
337
|
+
"value": opt_str(arena, value),
|
|
338
|
+
})
|
|
339
|
+
} else {
|
|
340
|
+
json!({})
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
_ => json!({}),
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/// The first node (in document order) that sits more than `limit` levels
|
|
348
|
+
/// below `root`, if any.
|
|
349
|
+
///
|
|
350
|
+
/// Deliberately iterative: it runs before the recursive `node_to_json`, so
|
|
351
|
+
/// it must not be the thing that overflows the stack on the input it exists
|
|
352
|
+
/// to reject.
|
|
353
|
+
pub fn first_node_deeper_than(arena: &Arena<Mdast>, root: u32, limit: usize) -> Option<u32> {
|
|
354
|
+
let mut pending = vec![(root, 0usize)];
|
|
355
|
+
while let Some((id, depth)) = pending.pop() {
|
|
356
|
+
if depth > limit {
|
|
357
|
+
return Some(id);
|
|
358
|
+
}
|
|
359
|
+
let parent = MdastNodeType::from_u8(arena.get_node(id).node_type).is_some_and(is_parent);
|
|
360
|
+
if parent {
|
|
361
|
+
pending.extend(
|
|
362
|
+
arena
|
|
363
|
+
.get_children(id)
|
|
364
|
+
.iter()
|
|
365
|
+
.rev()
|
|
366
|
+
.map(|&child| (child, depth + 1)),
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
None
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/// Recursively renders one arena node (and its subtree) as mdast JSON.
|
|
374
|
+
pub fn node_to_json(arena: &Arena<Mdast>, id: u32) -> Value {
|
|
375
|
+
let node = arena.get_node(id);
|
|
376
|
+
let nt = MdastNodeType::from_u8(node.node_type);
|
|
377
|
+
let type_name = nt.map(|t| t.name()).unwrap_or("unknown").to_string();
|
|
378
|
+
|
|
379
|
+
let mut obj = Map::new();
|
|
380
|
+
obj.insert("type".into(), Value::String(type_name));
|
|
381
|
+
if let Some(nt) = nt {
|
|
382
|
+
if let Value::Object(fields) = type_fields(arena, id, nt) {
|
|
383
|
+
for (k, v) in fields {
|
|
384
|
+
obj.insert(k, v);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if let Some(nt) = nt {
|
|
390
|
+
if is_parent(nt) {
|
|
391
|
+
let children = arena.get_children(id);
|
|
392
|
+
let arr: Vec<Value> = children.iter().map(|&c| node_to_json(arena, c)).collect();
|
|
393
|
+
obj.insert("children".into(), Value::Array(arr));
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
obj.insert("position".into(), position(arena, id));
|
|
398
|
+
|
|
399
|
+
Value::Object(obj)
|
|
400
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Andromeda
|
|
6
|
+
# Publishes images that live next to content so the asset pipeline can serve
|
|
7
|
+
# them.
|
|
8
|
+
#
|
|
9
|
+
# Adding `app/content` to Propshaft's load path would be shorter, but
|
|
10
|
+
# Propshaft serves *every* file in a load path — the Markdown sources
|
|
11
|
+
# themselves would become public URLs. Copying only the referenced images
|
|
12
|
+
# into `app/assets/builds/andromeda/` keeps the content directory private and
|
|
13
|
+
# mirrors how tailwindcss-rails treats `app/assets/builds`.
|
|
14
|
+
module Assets
|
|
15
|
+
# Relative to `app/assets/builds`, and therefore also the prefix of the
|
|
16
|
+
# logical asset path the view helpers resolve.
|
|
17
|
+
DIRECTORY = "andromeda"
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# Copies `source` under the builds directory and returns its logical path
|
|
22
|
+
# (what `asset_path` takes), or nil when the file is not a publishable
|
|
23
|
+
# asset under the content root.
|
|
24
|
+
#
|
|
25
|
+
# @param source [String] absolute path to a file inside the content root.
|
|
26
|
+
# @param content_root [String] the collection's base directory.
|
|
27
|
+
# @return [String, nil] e.g. "andromeda/blog/post/cover.png".
|
|
28
|
+
def publish(source, content_root:)
|
|
29
|
+
logical = logical_path(source, content_root: content_root)
|
|
30
|
+
return nil if logical.nil?
|
|
31
|
+
|
|
32
|
+
return logical if asset_pipeline_path(File.expand_path(source))
|
|
33
|
+
|
|
34
|
+
destination = File.join(builds_root, logical)
|
|
35
|
+
FileUtils.mkdir_p(File.dirname(destination))
|
|
36
|
+
# Copying only when the content changed keeps `andromeda:build` cheap on
|
|
37
|
+
# image-heavy sites, where the copy would otherwise dominate the run.
|
|
38
|
+
FileUtils.cp(source, destination) unless identical?(source, destination)
|
|
39
|
+
logical
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Conversion cannot know an image's final URL: Propshaft only learns the
|
|
43
|
+
# digest when `assets:precompile` runs, which is *after* `andromeda:build`
|
|
44
|
+
# (it depends on it), and in development the load path is indexed at boot.
|
|
45
|
+
# So the stored HTML carries this marker and the view resolves it, which
|
|
46
|
+
# costs one gsub per render and keeps digests correct in both environments.
|
|
47
|
+
MARKER = "andromeda-asset:"
|
|
48
|
+
|
|
49
|
+
def marker_for(logical_path)
|
|
50
|
+
"#{MARKER}#{logical_path}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# @param html [String] stored HTML, possibly containing markers.
|
|
54
|
+
# @return [String] the same HTML with every marker replaced by a real URL.
|
|
55
|
+
def resolve(html)
|
|
56
|
+
return html unless html.include?(MARKER)
|
|
57
|
+
|
|
58
|
+
html.gsub(/#{Regexp.escape(MARKER)}([^"'\s)]+)/) { url_for(Regexp.last_match(1)) }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @return [String] a URL for a logical path, digest-stamped when the asset
|
|
62
|
+
# pipeline is available and a plain /assets path otherwise.
|
|
63
|
+
def url_for(logical_path)
|
|
64
|
+
if defined?(::ActionController::Base)
|
|
65
|
+
::ActionController::Base.helpers.asset_path(logical_path)
|
|
66
|
+
else
|
|
67
|
+
File.join("/assets", logical_path)
|
|
68
|
+
end
|
|
69
|
+
rescue StandardError
|
|
70
|
+
# An image that has not been indexed yet (a fresh copy in development,
|
|
71
|
+
# or a precompile that has not run) must not take the whole page down.
|
|
72
|
+
File.join("/assets", logical_path)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def logical_path(source, content_root:)
|
|
76
|
+
source = File.expand_path(source)
|
|
77
|
+
root = File.expand_path(content_root)
|
|
78
|
+
project = project_root
|
|
79
|
+
|
|
80
|
+
# Nothing outside the project (or the content root, when a collection
|
|
81
|
+
# lives elsewhere) may be published: a `../` chain in a content file
|
|
82
|
+
# must not expose an arbitrary file on the machine.
|
|
83
|
+
inside = [project, root].any? { |boundary| source.start_with?("#{boundary}#{File::SEPARATOR}") }
|
|
84
|
+
return nil unless inside
|
|
85
|
+
|
|
86
|
+
if source.start_with?("#{root}#{File::SEPARATOR}")
|
|
87
|
+
File.join(DIRECTORY, File.basename(root), source.delete_prefix("#{root}#{File::SEPARATOR}"))
|
|
88
|
+
elsif (asset_relative = asset_pipeline_path(source))
|
|
89
|
+
asset_relative
|
|
90
|
+
else
|
|
91
|
+
# Somewhere else in the project (Astro's own example keeps hero
|
|
92
|
+
# images in `src/assets`): mirrored under the build directory so it
|
|
93
|
+
# still gets a digest, with the project-relative path kept to avoid
|
|
94
|
+
# collisions between same-named files.
|
|
95
|
+
File.join(DIRECTORY, "project", source.delete_prefix("#{project}#{File::SEPARATOR}"))
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# A file the asset pipeline already serves needs no copy -- returning its
|
|
100
|
+
# logical path lets `asset_path` digest the original in place.
|
|
101
|
+
def asset_pipeline_path(source)
|
|
102
|
+
assets_root = File.join(project_root, "app/assets")
|
|
103
|
+
return nil unless source.start_with?("#{assets_root}#{File::SEPARATOR}")
|
|
104
|
+
|
|
105
|
+
relative = source.delete_prefix("#{assets_root}#{File::SEPARATOR}")
|
|
106
|
+
# Propshaft's load path is each directory under app/assets, so the
|
|
107
|
+
# first segment (images/, stylesheets/, builds/, ...) is not part of
|
|
108
|
+
# the logical path -- except for files sitting directly in app/assets.
|
|
109
|
+
relative.include?(File::SEPARATOR) ? relative.split(File::SEPARATOR, 2).last : relative
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def project_root
|
|
113
|
+
Andromeda.config.project_root
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def builds_root
|
|
117
|
+
File.join(project_root, "app/assets/builds")
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Compares bytes, not timestamps: a checkout or an extracted archive can
|
|
121
|
+
# give a changed image an old mtime, and a same-sized edit would then
|
|
122
|
+
# never be copied. The size check keeps the common case cheap.
|
|
123
|
+
def identical?(source, destination)
|
|
124
|
+
File.exist?(destination) && File.size(source) == File.size(destination) &&
|
|
125
|
+
FileUtils.compare_file(source, destination)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Andromeda
|
|
4
|
+
# The read-only counterpart of `andromeda:build`, for CI.
|
|
5
|
+
#
|
|
6
|
+
# It deliberately runs the *same* conversion the build runs, into a
|
|
7
|
+
# throwaway store, rather than re-implementing a subset of the checks: a
|
|
8
|
+
# check that passes while the build fails would be worse than no check at
|
|
9
|
+
# all. Everything it finds is reported together, because fixing content
|
|
10
|
+
# one error per CI run is miserable.
|
|
11
|
+
module Check
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# @param entry_classes [Array<Class>] defaults to every registered collection.
|
|
15
|
+
# @return [Array<String>] human-readable problems; empty when content is healthy.
|
|
16
|
+
def run(entry_classes = Andromeda::Pipeline.default_entry_classes)
|
|
17
|
+
problems = []
|
|
18
|
+
|
|
19
|
+
entry_classes.each do |entry_class|
|
|
20
|
+
entries = load_entries(entry_class, problems)
|
|
21
|
+
next if entries.nil?
|
|
22
|
+
|
|
23
|
+
entries.each do |entry|
|
|
24
|
+
problems.concat(frontmatter_style_problems(entry))
|
|
25
|
+
problems.concat(conversion_problems(entry))
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
problems
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def load_entries(entry_class, problems)
|
|
33
|
+
entry_class.reload!
|
|
34
|
+
entry_class.all.to_a
|
|
35
|
+
rescue Andromeda::LoaderError => e
|
|
36
|
+
problems.concat(e.messages.map { |message| "[#{entry_class.collection_name}] #{message}" })
|
|
37
|
+
nil
|
|
38
|
+
rescue Andromeda::Error => e
|
|
39
|
+
problems << "[#{entry_class.collection_name}] #{e.message}"
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Non-snake_case keys are legal YAML but will not reach a declared
|
|
44
|
+
# attribute, so they are reported here with the command that rewrites
|
|
45
|
+
# them instead of failing later as a missing required field.
|
|
46
|
+
def frontmatter_style_problems(entry)
|
|
47
|
+
offenders = Andromeda::Frontmatter.non_snake_case_keys(entry.data)
|
|
48
|
+
return [] if offenders.empty?
|
|
49
|
+
|
|
50
|
+
offenders.map do |key|
|
|
51
|
+
"#{entry.file_path}: frontmatter key #{key} is not snake_case; run `bin/rails andromeda:fix`"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def conversion_problems(entry)
|
|
56
|
+
Andromeda::Pipeline.new(store: NullStore.new, mode: :development).convert(entry)
|
|
57
|
+
[]
|
|
58
|
+
rescue Andromeda::Error => e
|
|
59
|
+
["#{entry.file_path}: #{e.message}"]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Swallows the writes so a check can never leave `.andromeda/` in a
|
|
63
|
+
# half-built state that a later production boot would happily serve.
|
|
64
|
+
class NullStore
|
|
65
|
+
def write_entry(*) = nil
|
|
66
|
+
def read_entry(*) = nil
|
|
67
|
+
def read_index(*) = nil
|
|
68
|
+
def replace_collection!(*) = nil
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Andromeda
|
|
4
|
+
class Components
|
|
5
|
+
# Raised when a component tag resolves to a partial name (by explicit
|
|
6
|
+
# registration, import path, or naming convention) but no such partial
|
|
7
|
+
# exists on disk. Names the tag, the content file, the line, and the
|
|
8
|
+
# exact path to create so the error is actionable straight out
|
|
9
|
+
# of a build log or a development error page, without having to go
|
|
10
|
+
# spelunking through the resolution rules to figure out what Andromeda
|
|
11
|
+
# was even looking for.
|
|
12
|
+
class MissingPartialError < Andromeda::Error
|
|
13
|
+
attr_reader :tag_name, :path, :line, :expected_path
|
|
14
|
+
|
|
15
|
+
def initialize(tag_name, path:, line:, expected_path:)
|
|
16
|
+
@tag_name = tag_name
|
|
17
|
+
@path = path
|
|
18
|
+
@line = line
|
|
19
|
+
@expected_path = expected_path
|
|
20
|
+
|
|
21
|
+
location = [path, line].compact.join(":")
|
|
22
|
+
super(
|
|
23
|
+
"#{location}: <#{tag_name}> has no matching partial. Create #{expected_path} " \
|
|
24
|
+
"(e.g. `rails g andromeda:component #{tag_name}`), register it explicitly with " \
|
|
25
|
+
"`Andromeda::Components.register(#{tag_name.to_s.inspect}, \"partial/path\")`, or " \
|
|
26
|
+
"import it from a path that points at an existing partial."
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Raised for a member-expression tag name (`<Tabs.Item>`) that
|
|
32
|
+
# was not explicitly registered. Convention-based resolution can't turn
|
|
33
|
+
# a dotted name into a partial path, and guessing would be more
|
|
34
|
+
# surprising than just saying so -- named-slot components (the
|
|
35
|
+
# `Tabs`/`Fragment slot=` pattern) cover the common case this syntax is
|
|
36
|
+
# usually reaching for.
|
|
37
|
+
class InvalidComponentNameError < Andromeda::Error
|
|
38
|
+
attr_reader :tag_name, :path, :line
|
|
39
|
+
|
|
40
|
+
def initialize(tag_name, path:, line:)
|
|
41
|
+
@tag_name = tag_name
|
|
42
|
+
@path = path
|
|
43
|
+
@line = line
|
|
44
|
+
|
|
45
|
+
location = [path, line].compact.join(":")
|
|
46
|
+
super(
|
|
47
|
+
"#{location}: <#{tag_name}> looks like a member expression (e.g. `Tabs.Item`), " \
|
|
48
|
+
"which Andromeda does not resolve by naming convention in v0 -- register it " \
|
|
49
|
+
"explicitly with `Andromeda::Components.register(#{tag_name.to_s.inspect}, " \
|
|
50
|
+
"\"partial/path\")`"
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Raised for a `{...}` expression that is neither a literal,
|
|
56
|
+
# a `frontmatter.x` reference, nor a comment -- the
|
|
57
|
+
# v0 static-expression subset. Everything else (`.map()`,
|
|
58
|
+
# ternaries, template interpolation, arrow functions, `await`, ...) is
|
|
59
|
+
# explicitly out of scope, and silently dropping it would hide content
|
|
60
|
+
# bugs, so this raises naming the file/line rather than degrading
|
|
61
|
+
# quietly.
|
|
62
|
+
class UnsupportedExpressionError < Andromeda::Error
|
|
63
|
+
attr_reader :source, :path, :line, :column
|
|
64
|
+
|
|
65
|
+
def initialize(source, path:, line:, column:)
|
|
66
|
+
@source = source
|
|
67
|
+
@path = path
|
|
68
|
+
@line = line
|
|
69
|
+
@column = column
|
|
70
|
+
|
|
71
|
+
location = [path, line].compact.join(":")
|
|
72
|
+
super(
|
|
73
|
+
"#{location}: unsupported MDX expression `{#{source}}` -- only literals, " \
|
|
74
|
+
"`frontmatter.x` references, and comments are supported"
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Raised when there is no Rails renderer to hand a partial to -- either
|
|
80
|
+
# `ApplicationController` is not defined (Rails is not loaded / not
|
|
81
|
+
# booted yet) and no `view:` was passed explicitly to
|
|
82
|
+
# `Andromeda::Components.new`. Components render through
|
|
83
|
+
# `ApplicationController.renderer`; there is no non-Rails fallback in
|
|
84
|
+
# v0 (a plain ActionView::Base renderer setup is enough of a project on
|
|
85
|
+
# its own -- view paths, compiled template caching, helpers -- that it
|
|
86
|
+
# is deferred rather than half-done here).
|
|
87
|
+
class NoRendererAvailableError < Andromeda::Error
|
|
88
|
+
def initialize(path)
|
|
89
|
+
super(
|
|
90
|
+
"no Rails renderer available to render MDX components in " \
|
|
91
|
+
"#{path || "(unknown file)"} -- define ApplicationController, or pass `view:` " \
|
|
92
|
+
"explicitly to Andromeda::Components.new"
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|