andromeda_cms 0.6.2 → 0.7.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 64670cf71e6544d5427eeb01b7fbd90eda3a717dbb28379dd836df5662d24563
4
- data.tar.gz: 7966c38ad14347e3161f3a8f7018daa5438dddb00aacfee099e9d2c014e500d9
3
+ metadata.gz: df0dda6ff30ba8c1244c31c1af9951993028e7056ae943b4cca5513c14c499d2
4
+ data.tar.gz: 76f8e9c8de6a02d0ca9c8d22e40946c5e3bfb5961fa6888781add1d0a7e3195a
5
5
  SHA512:
6
- metadata.gz: b6fe4b7a80ca8828ef6ecfeb77ec1ceb8ec5717df836bd6f68e328adc5e9fdb162e1607d26b92167d7420639874c5a203ea25bcf7325d5952a4acbb1f76b76c3
7
- data.tar.gz: 5db3fa88aab475e40522a9024d8d22f8887ec106fad290fbfec7b18addb13ab68dba3db3e48e080d43603c3c50a6439e14c6b0fec912ba40a8dba270fae82799
6
+ metadata.gz: eac17b62179d28bb3ce45a772d0a9a2a630098c841d8468436d4a9b3239c9fb83d8f841252ffe42500a420cc11679e75d0bb3b0333a72ef535147bdd0021df60
7
+ data.tar.gz: 3dfae8e7efde1146d5b8881c8654acc4b527d1dc65b661259a658fa82ef3aef5cffc59be30e483f53fc7e3e892e37071e68cf46a95905e530aa1e6af85cb3a47
data/CHANGELOG.md CHANGED
@@ -5,6 +5,43 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.7.0] - 2026-10-16
9
+
10
+ ### Changed
11
+
12
+ - YAML frontmatter is now read the way Astro 7.3.6 and later read it. Astro
13
+ replaced js-yaml with the `yaml` package (core schema, timestamps, merge
14
+ keys), and Andromeda follows. Plain scalars that only YAML 1.1 or Psych
15
+ treat as numbers are now strings, as they are in Astro: values with
16
+ underscores (`1_000`, `0x1_F`), the `0b` prefix, and signed hexadecimal
17
+ or octal (`-0x1F`). Numbers in the core schema are unchanged, including
18
+ `010` (10), `0o17`, `0x1F` and `1e3`.
19
+ - A mapping that repeats a key now raises `Andromeda::SyntaxError` at the
20
+ second key, with its line and column, as Astro does. It used to keep the
21
+ last value silently. Keys are compared after resolution, so `1` and `01`
22
+ collide while `1` and `"1"` do not; repeated `<<` merge keys are allowed.
23
+ Content that repeats a key will fail to build until the duplicate is
24
+ removed.
25
+
26
+ ## [0.6.3] - 2026-10-16
27
+
28
+ ### Fixed
29
+
30
+ - `andromeda:import_astro` no longer silently mishandles a `glob()` `pattern`
31
+ it cannot carry over. An array pattern is reduced to its first
32
+ non-exclusion element (it used to take the first string, even a `!`
33
+ exclusion), and a note in the report's "Needs a human" section plus a
34
+ `# TODO` above the generated `collection` line name what was lost:
35
+ exclusions that are not applied (those files are still copied and
36
+ loaded) and extra patterns that were dropped (those files are copied but
37
+ not loaded). An extglob pattern such as `!(drafts)/**/*.md`, which Ruby's
38
+ glob cannot match, is flagged the same way instead of leaving the
39
+ collection empty.
40
+ - The gem now requires `tomlrb ~> 2.0`. tomlrb 3.0 removed
41
+ `Tomlrb::Scanner`, which TOML frontmatter parsing uses to report the line
42
+ of a syntax error, so on Ruby 3.3 and later a fresh install picked up 3.0
43
+ and every TOML frontmatter raised `NameError`.
44
+
8
45
  ## [0.6.2] - 2026-10-09
9
46
 
10
47
  ### Fixed
@@ -189,6 +226,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
189
226
  - Generators: `andromeda:install`, `andromeda:collection`,
190
227
  `andromeda:component` and `andromeda:import_astro`.
191
228
 
229
+ [0.7.0]: https://github.com/AndromedaCMS/andromeda_cms/releases/tag/v0.7.0
230
+ [0.6.3]: https://github.com/AndromedaCMS/andromeda_cms/releases/tag/v0.6.3
192
231
  [0.6.2]: https://github.com/AndromedaCMS/andromeda_cms/releases/tag/v0.6.2
193
232
  [0.6.1]: https://github.com/AndromedaCMS/andromeda_cms/releases/tag/v0.6.1
194
233
  [0.6.0]: https://github.com/AndromedaCMS/andromeda_cms/releases/tag/v0.6.0
@@ -25,45 +25,53 @@ module Andromeda
25
25
  # a non-blank line is seen, no `---`/`+++` after it is a fence.
26
26
  LEADING_RE = /\A\uFEFF?(?:[ \t]*\r?\n)*/
27
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.
28
+ # Psych resolves plain scalars by YAML 1.1 rules; Astro 7.3.6 and later
29
+ # read frontmatter with the `yaml` package (core schema, `timestamp` tag,
30
+ # merge keys), which follows YAML 1.2's core schema. The two disagree on
31
+ # values that really do appear in hand-written frontmatter, and a silent
32
+ # disagreement is worse than an error: `time: 12:30` becoming the integer
33
+ # 45000 passes every schema check and then displays nonsense. Each rule
34
+ # below mirrors what that parser does with the same plain scalar.
34
35
  class ScalarScanner < Psych::ScalarScanner
35
36
  # 1.1-only booleans; 1.2 keeps them as strings.
36
37
  YAML_1_1_ONLY_BOOL = /\A(?:[Yy]es|YES|[Nn]o|NO|[Oo]n|ON|[Oo]ff|OFF|[Yy]|[Nn])\z/
37
38
  # 1.1 base-60 (`1:30`) and comma-grouped (`1,000`) numbers; 1.2 has
38
39
  # neither, so these stay strings.
39
40
  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/
41
+ # The core schema's integers. A leading zero is plain decimal (`010` is
42
+ # 10, where 1.1 reads octal); `0o` octal and `0x` hex take no sign.
43
+ INTEGER = /\A[-+]?[0-9]+\z/
44
+ OCTAL = /\A0o[0-7]+\z/
45
+ HEX = /\A0x[0-9a-fA-F]+\z/
46
+ # The core schema's floats, including an exponent without a decimal
47
+ # point (`1e3`), which Psych rejects. `.inf`/`.nan` are left to Psych.
48
+ FLOAT = /\A[-+]?(?:\.[0-9]+|[0-9]+(?:\.[0-9]*)?)(?:[eE][-+]?[0-9]+)?\z/
49
+ # Number-like spellings Psych accepts but the core schema does not
50
+ # (`1_000`, `0b1010`, `-0x1F`, `0o1_7`); they stay strings.
51
+ NON_CORE_NUMBER = /\A[-+]?(?:0[xXbBoO][0-9a-fA-F_]+|(?:[0-9][0-9_]*(?:\.[0-9_]*)?|\.[0-9_]+)(?:[eE][-+]?[0-9_]+)?)\z/
46
52
 
47
53
  def tokenize(string)
48
54
  case string
49
55
  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("_")))
56
+ when INTEGER then Integer(string, 10)
57
+ when OCTAL then Integer(string[2..], 8)
58
+ when HEX then Integer(string, 16)
59
+ when FLOAT then Float(normalize_float(string))
60
+ when NON_CORE_NUMBER then string
53
61
  else super
54
62
  end
55
63
  end
56
64
 
57
65
  private
58
66
 
59
- # Ruby's Float() rejects the `1.e3` and `.5e3` spellings YAML allows.
67
+ # Ruby's Float() rejects the `1.`, `1.e3` and `.5e3` spellings YAML allows.
60
68
  def normalize_float(string)
61
- string.sub(/\.(?=[eE])/, ".0").sub(/\A([-+]?)\./, "\\10.")
69
+ string.sub(/\.(?=[eE]|\z)/, ".0").sub(/\A([-+]?)\./, "\\10.")
62
70
  end
63
71
  end
64
72
 
65
- # Restricts `!!` tags to Date/Time (matching js-yaml's default schema,
66
- # which also has no notion of arbitrary Ruby classes) while still
73
+ # Restricts `!!` tags to Date/Time (matching the `yaml` package's core
74
+ # schema, which also has no notion of arbitrary Ruby classes) while still
67
75
  # permitting the two classes plain `YYYY-MM-DD`/timestamp scalars resolve
68
76
  # to, so unquoted dates round-trip instead of raising
69
77
  # Psych::DisallowedClass.
@@ -76,7 +84,7 @@ module Andromeda
76
84
  end
77
85
 
78
86
  # Plain (non-"safe") visitor so anchors/aliases resolve instead of
79
- # raising -- js-yaml allows them by default, and copied Astro content may
87
+ # raising -- the `yaml` package resolves them by default, and copied Astro content may
80
88
  # rely on them.
81
89
  class ToRuby < Psych::Visitors::ToRuby
82
90
  def initialize
@@ -199,6 +207,8 @@ module Andromeda
199
207
  return {} if node.nil?
200
208
 
201
209
  reject_alias_explosion!(node, path: path, content_start_line: content_start_line)
210
+ reject_duplicate_keys!(node, ScalarScanner.new(ClassLoader.new), path: path,
211
+ content_start_line: content_start_line)
202
212
  ToRuby.new.accept(node) || {}
203
213
  rescue Psych::SyntaxError => e
204
214
  # Psych's own #line/#column are 0-based and relative to `text` (the
@@ -305,6 +315,36 @@ module Andromeda
305
315
  end
306
316
  private_class_method :expanded_size
307
317
 
318
+ # Astro's `yaml` parser reports a repeated mapping key as an error, where
319
+ # Psych silently keeps the last value. Keys are compared after resolution,
320
+ # so `1` and `01` collide while `1` and `"1"` do not. Plain `<<` merge
321
+ # keys, alias keys and collection keys are left out.
322
+ def reject_duplicate_keys!(node, scanner, path:, content_start_line:)
323
+ if node.is_a?(Psych::Nodes::Mapping)
324
+ seen = {}
325
+ node.children.each_slice(2) do |key, _value|
326
+ next unless key.is_a?(Psych::Nodes::Scalar)
327
+ next if key.plain && key.value == "<<"
328
+
329
+ resolved = key.plain ? scanner.tokenize(key.value) : key.value
330
+ resolved = resolved.to_f if resolved.is_a?(Numeric)
331
+ if seen.key?(resolved)
332
+ raise syntax_error("duplicate key #{key.value.inspect}", path: path,
333
+ content_start_line: content_start_line,
334
+ relative_line: key.start_line,
335
+ relative_column: key.start_column)
336
+ end
337
+
338
+ seen[resolved] = true
339
+ end
340
+ end
341
+
342
+ node.children.to_a.each do |child|
343
+ reject_duplicate_keys!(child, scanner, path: path, content_start_line: content_start_line)
344
+ end
345
+ end
346
+ private_class_method :reject_duplicate_keys!
347
+
308
348
  def syntax_error(message, path:, content_start_line:, relative_line:, relative_column:)
309
349
  Andromeda::SyntaxError.new(
310
350
  message,
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Andromeda
4
- VERSION = "0.6.2"
4
+ VERSION = "0.7.0"
5
5
  end
@@ -18,7 +18,19 @@ module Andromeda
18
18
  # already makes for MDX import statements. Anything shaped differently
19
19
  # falls through to `Attribute#unsupported?` rather than guessing.
20
20
  module ContentConfigConverter
21
- Collection = Struct.new(:name, :base, :pattern, :attributes, keyword_init: true)
21
+ # `notes` are things about the collection itself (not one field)
22
+ # that still need a human -- e.g. a `pattern` this importer cannot
23
+ # carry over as written. Defaults to empty so existing
24
+ # `Collection.new` calls stay valid.
25
+ Collection = Struct.new(:name, :base, :pattern, :attributes, :notes, keyword_init: true) do
26
+ def initialize(notes: [], **rest) = super(notes: notes, **rest)
27
+ end
28
+
29
+ DEFAULT_PATTERN = "**/*.{md,mdx}"
30
+
31
+ # Bash-style extglob openers (`!(drafts)`, `@(a|b)`, ...), which
32
+ # Astro's glob loader accepts but Ruby's `Dir.glob` does not.
33
+ EXTGLOB_RE = /[!@+*?]\(/.freeze
22
34
 
23
35
  # One frontmatter field. `ruby_type` is nil when the Zod expression
24
36
  # could not be recognized at all -- `source` (the original
@@ -65,16 +77,64 @@ module Andromeda
65
77
  def build_collection(name, body, astro_root:)
66
78
  body = BalancedScanner.strip_comments(body)
67
79
  base = extract_string(body, /base\s*:/) || "./src/content/#{name}"
68
- pattern = extract_string(body, /pattern\s*:/) || "**/*.{md,mdx}"
80
+ pattern, notes = extract_pattern(body)
69
81
 
70
82
  Collection.new(
71
83
  name: name,
72
84
  base: File.expand_path(base, astro_root),
73
85
  pattern: pattern,
74
- attributes: extract_attributes(body)
86
+ attributes: extract_attributes(body),
87
+ notes: notes
75
88
  )
76
89
  end
77
90
 
91
+ # Astro's `glob({ pattern: ... })` also takes an array (with `!`
92
+ # exclusions) and extglob syntax -- neither of which `Dir.glob`
93
+ # understands. Rather than silently dropping them, the closest
94
+ # usable pattern is kept and a note says what was lost.
95
+ #
96
+ # @return [Array(String, Array<String>)] the pattern and any notes.
97
+ def extract_pattern(body)
98
+ match = /pattern\s*:\s*\[/.match(body)
99
+ return extract_array_pattern(body, match.end(0) - 1) if match
100
+
101
+ pattern = extract_string(body, /pattern\s*:/) || DEFAULT_PATTERN
102
+ return [pattern, []] unless pattern.match?(EXTGLOB_RE)
103
+
104
+ [pattern, ["pattern `#{pattern}` uses extglob syntax, which Ruby's glob does not support -- " \
105
+ "no files will match this pattern"]]
106
+ end
107
+
108
+ # Only what was actually lost gets a note: `!` exclusions (still
109
+ # copied and loaded) and any further non-exclusion patterns (copied
110
+ # but not loaded). A one-element or empty array loses nothing.
111
+ def extract_array_pattern(body, open_index)
112
+ close = BalancedScanner.matching_close(body, open_index)
113
+ # Squished onto one line: the note also becomes a `# TODO` comment.
114
+ source = body[open_index..close].gsub(/\s+/, " ")
115
+ elements = BalancedScanner.split_top_level(body[(open_index + 1)...close]).map do |literal|
116
+ literal.gsub(/\A(['"`])(.*)\1\z/, '\2')
117
+ end.reject(&:empty?)
118
+ exclusions, includes = elements.partition { |element| element.start_with?("!") }
119
+ pattern = includes.first || DEFAULT_PATTERN
120
+ dropped = includes.drop(1)
121
+
122
+ notes = []
123
+ unless exclusions.empty?
124
+ notes << "exclusions #{backticked(exclusions)} from pattern array #{source} are not applied, " \
125
+ "so the files they exclude are still copied and loaded"
126
+ end
127
+ unless dropped.empty?
128
+ notes << "patterns #{backticked(dropped)} from pattern array #{source} were dropped " \
129
+ "(only `#{pattern}` was kept), so files matching only them are copied but not loaded"
130
+ end
131
+ [pattern, notes]
132
+ end
133
+
134
+ def backticked(elements)
135
+ elements.map { |element| "`#{element}`" }.join(", ")
136
+ end
137
+
78
138
  # @return [String, nil] the quoted value right after `label_re`, e.g.
79
139
  # `base: './src/content/blog'` -> `"./src/content/blog"`.
80
140
  def extract_string(body, label_re)
@@ -80,7 +80,10 @@ module Andromeda
80
80
  fallback_collections
81
81
  end
82
82
 
83
- @collections.each { |collection| refine_with_json_schema!(collection) }
83
+ @collections.each do |collection|
84
+ refine_with_json_schema!(collection)
85
+ collection.notes.each { |note| @report[:notes] << "#{collection.name}: #{note}" }
86
+ end
84
87
  end
85
88
 
86
89
  # The fallback clause: every top-level directory under
@@ -231,6 +234,9 @@ module Andromeda
231
234
  lines = ["# frozen_string_literal: true", ""]
232
235
  namespace_modules.each_with_index { |mod, i| lines << "#{" " * i}module #{mod}" }
233
236
  lines << "#{indent}class #{collection.name.to_s.singularize.camelize} < Andromeda::Entry"
237
+ collection.notes.each do |note|
238
+ lines << "#{indent} # TODO: #{note} (content.config.ts) -- fix `pattern` by hand."
239
+ end
234
240
  lines << "#{indent} collection :#{collection.name}, base: \"app/content/#{collection.name}\", " \
235
241
  "pattern: \"#{collection.pattern}\""
236
242
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: andromeda_cms
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.2
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - LIMHAUS Inc.
@@ -55,14 +55,14 @@ dependencies:
55
55
  name: tomlrb
56
56
  requirement: !ruby/object:Gem::Requirement
57
57
  requirements:
58
- - - ">="
58
+ - - "~>"
59
59
  - !ruby/object:Gem::Version
60
60
  version: '2.0'
61
61
  type: :runtime
62
62
  prerelease: false
63
63
  version_requirements: !ruby/object:Gem::Requirement
64
64
  requirements:
65
- - - ">="
65
+ - - "~>"
66
66
  - !ruby/object:Gem::Version
67
67
  version: '2.0'
68
68
  - !ruby/object:Gem::Dependency