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,383 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "time"
5
+
6
+ module Andromeda
7
+ # A resolved `image()` attribute. Only holds enough
8
+ # to prove the file exists and to locate it again -- no `url` yet, since
9
+ # turning a content-relative path into a served asset URL is Propshaft's
10
+ # job, not the schema's.
11
+ # `asset` is filled in by the conversion pipeline once the file has been
12
+ # copied into the asset pipeline; validation itself only proves the file
13
+ # exists and stays inside the content root.
14
+ Image = Struct.new(:path, :relative_path, :asset, keyword_init: true) do
15
+ def to_s = relative_path
16
+ end
17
+
18
+ # A resolved `reference()` attribute, normalized to `{collection,
19
+ # id}` exactly like Astro. Resolving `id` into the actual target entry
20
+ # needs the collection registry, which does not exist at schema-validation
21
+ # time -- this is deliberately just a pointer.
22
+ Reference = Struct.new(:collection, :id, keyword_init: true)
23
+
24
+ # A declarative set of attribute definitions for one entry type (a
25
+ # Rails-style DSL, not Zod-style). `Schema` itself is loader/class-agnostic
26
+ # -- it validates a Hash and returns a Hash -- so the class-level
27
+ # `attribute` macro that `Andromeda::Entry` offers can be a
28
+ # thin wrapper that builds and delegates to one of these instead of
29
+ # duplicating any of this logic.
30
+ #
31
+ # schema = Andromeda::Schema.new do |s|
32
+ # s.attribute :title, :string, required: true
33
+ # s.attribute :pub_date, :date, required: true
34
+ # s.attribute :draft, :boolean, default: false
35
+ # s.attribute :tags, :array, of: :string, default: []
36
+ # end
37
+ #
38
+ # result = schema.validate({ "title" => "Hello", "pub_date" => "Jul 08 2022" })
39
+ # result.valid? # => true
40
+ # result.data # => { title: "Hello", pub_date: #<Date: 2022-07-08>, draft: false, tags: [] }
41
+ #
42
+ # schema.validate!(data, path: "app/content/blog/x.md") # raises Andromeda::ValidationError on failure
43
+ class Schema
44
+ TYPES = %i[string integer float boolean date datetime array hash enum image reference].freeze
45
+
46
+ # One declared attribute. Plain data, no behavior beyond `default_value`
47
+ # -- everything else lives in Schema so a synthetic Attribute (built for
48
+ # `of:` element coercion, see `#coerce_array`) works the same way a
49
+ # top-level one does.
50
+ Attribute = Struct.new(:name, :type, :required, :default, :of, :values, :collection, keyword_init: true) do
51
+ # Called once per missing, non-required attribute. Arrays/Hashes are
52
+ # duped so every entry lacking the attribute gets its own object --
53
+ # otherwise all of them would share (and could mutate) the same
54
+ # literal from the `attribute` call.
55
+ def default_value
56
+ return default.call if default.respond_to?(:call)
57
+ return default.dup if default.is_a?(Array) || default.is_a?(Hash) || default.is_a?(String)
58
+
59
+ default
60
+ end
61
+ end
62
+
63
+ # One validation failure. `attribute` is nil for problems that are
64
+ # about a raw key rather than a declared attribute (currently only
65
+ # non-snake_case keys).
66
+ Problem = Struct.new(:attribute, :message, keyword_init: true) do
67
+ def to_s = message
68
+ end
69
+
70
+ # The outcome of `Schema#validate`. `data` is only populated when
71
+ # `valid?` -- a partially-coerced Hash after some attributes failed
72
+ # would invite callers to use data that was never actually validated.
73
+ #
74
+ # `data` mixes Symbol keys (declared attributes, coerced, defaults
75
+ # applied) with String keys (unknown frontmatter, passed through
76
+ # untouched) in the *same* Hash. That is a deliberate choice: code that
77
+ # knows the schema reads `data[:title]` and gets a real Date/Boolean/etc,
78
+ # while code that doesn't (a generic dumper, `andromeda:check`) can still
79
+ # see whatever else was in the file under the key it was written with,
80
+ # rather than that data silently vanishing. `unknown_keys` is the same
81
+ # String-keyed subset on its own, for callers that only care about that.
82
+ Result = Struct.new(:data, :problems, :unknown_keys, keyword_init: true) do
83
+ def valid? = problems.empty?
84
+ end
85
+
86
+ def initialize
87
+ @attributes = {}
88
+ yield self if block_given?
89
+ end
90
+
91
+ attr_reader :attributes
92
+
93
+ # @param name [Symbol, String]
94
+ # @param type [Symbol] one of TYPES
95
+ # @param required [Boolean] absent or explicit-nil fails validation;
96
+ # an explicit `false` (booleans!) or `0` does not.
97
+ # @param default [Object, #call] used only when the key is absent from
98
+ # the data entirely -- see `#validate` for why an explicit nil does
99
+ # not also fall back to this.
100
+ # @param of [Symbol] element type, :array only.
101
+ # @param values [Array] allowed values, :enum only.
102
+ # @param collection [Symbol, String] target collection name, :reference only.
103
+ def attribute(name, type, required: false, default: nil, of: nil, values: nil, collection: nil)
104
+ name = name.to_sym
105
+
106
+ unless TYPES.include?(type)
107
+ raise ArgumentError, "#{name}: unknown attribute type #{type.inspect} (expected one of #{TYPES.join(", ")})"
108
+ end
109
+ raise ArgumentError, "#{name}: :enum requires values:" if type == :enum && (values.nil? || values.empty?)
110
+ raise ArgumentError, "#{name}: :reference requires collection:" if type == :reference && collection.nil?
111
+ if type == :array && of && !TYPES.include?(of)
112
+ raise ArgumentError, "#{name}: unknown element type #{of.inspect} for of:"
113
+ end
114
+
115
+ @attributes[name] = Attribute.new(
116
+ name: name, type: type, required: required, default: default,
117
+ of: of, values: values, collection: collection && collection.to_sym
118
+ )
119
+ self
120
+ end
121
+
122
+ # Validates `data` (String-keyed, as `Andromeda::Frontmatter.parse`
123
+ # returns it) against every declared attribute and returns **all**
124
+ # problems at once, Zod-style, rather than stopping at the first.
125
+ #
126
+ # @param data [Hash] frontmatter data with String keys.
127
+ # @param path [String, nil] source file, attributed on problems/errors.
128
+ # @param entry_path [String, nil] the entry file itself -- required to
129
+ # resolve :image attributes (relative to its directory).
130
+ # @param content_root [String, nil] the collection's base directory --
131
+ # required to reject :image paths that escape it.
132
+ # @return [Andromeda::Schema::Result]
133
+ def validate(data, path: nil, entry_path: nil, content_root: nil)
134
+ problems = []
135
+
136
+ Andromeda::Frontmatter.non_snake_case_keys(data).each do |key|
137
+ problems << Problem.new(attribute: nil, message: non_snake_case_message(key))
138
+ end
139
+
140
+ declared = {}
141
+ attributes.each_value do |attr|
142
+ key = attr.name.to_s
143
+
144
+ unless data.key?(key)
145
+ if attr.required
146
+ problems << Problem.new(attribute: attr.name, message: "#{attr.name} is required")
147
+ else
148
+ declared[attr.name] = attr.default_value
149
+ end
150
+ next
151
+ end
152
+
153
+ value = data[key]
154
+
155
+ if value.nil?
156
+ # A key that is present but explicitly blank (`title:` with
157
+ # nothing after it) means the author wrote *something* -- even if
158
+ # that something is "nothing in particular" -- so it is kept as
159
+ # nil rather than silently replaced by `default:`. Only a
160
+ # required attribute rejects it.
161
+ if attr.required
162
+ problems << Problem.new(attribute: attr.name, message: "#{attr.name} is required")
163
+ else
164
+ declared[attr.name] = nil
165
+ end
166
+ next
167
+ end
168
+
169
+ coerced, error = coerce(attr, value, entry_path: entry_path, content_root: content_root)
170
+ if error
171
+ problems << Problem.new(attribute: attr.name, message: "#{attr.name}: #{error}")
172
+ else
173
+ declared[attr.name] = coerced
174
+ end
175
+ end
176
+
177
+ known = attributes.keys.map(&:to_s)
178
+ unknown = data.each_with_object({}) do |(key, value), out|
179
+ out[key] = value if key.is_a?(String) && !known.include?(key)
180
+ end
181
+
182
+ Result.new(
183
+ data: problems.empty? ? declared.merge(unknown) : nil,
184
+ problems: problems,
185
+ unknown_keys: unknown
186
+ )
187
+ end
188
+
189
+ # @raise [Andromeda::ValidationError] listing every problem, unless valid.
190
+ # @return [Hash] `result.data` -- see Result for its Symbol/String key mix.
191
+ def validate!(data, path: nil, entry_path: nil, content_root: nil)
192
+ result = validate(data, path: path, entry_path: entry_path, content_root: content_root)
193
+ raise Andromeda::ValidationError.new(result.problems, path: path) unless result.valid?
194
+
195
+ result.data
196
+ end
197
+
198
+ private
199
+
200
+ def non_snake_case_message(key)
201
+ fixed = key.gsub(/([a-z0-9])([A-Z])/, '\1_\2').tr("-", "_").downcase
202
+ "#{key} is not snake_case (expected #{fixed}); run andromeda:fix to rename it"
203
+ end
204
+
205
+ # Returns `[coerced_value, nil]` on success or `[nil, error_message]`
206
+ # otherwise. Kept as one dispatcher (rather than a Hash of coercers) so
207
+ # `#coerce_array` can recurse into it for `of:` without building a
208
+ # second Attribute-shaped indirection layer.
209
+ def coerce(attr, value, entry_path:, content_root:)
210
+ case attr.type
211
+ when :string then coerce_string(value)
212
+ when :integer then coerce_integer(value)
213
+ when :float then coerce_float(value)
214
+ when :boolean then coerce_boolean(value)
215
+ when :date then coerce_date(value)
216
+ when :datetime then coerce_datetime(value)
217
+ when :array then coerce_array(attr, value, entry_path: entry_path, content_root: content_root)
218
+ when :hash then coerce_hash(value)
219
+ when :enum then coerce_enum(attr, value)
220
+ when :image then coerce_image(value, entry_path: entry_path, content_root: content_root)
221
+ when :reference then coerce_reference(attr, value)
222
+ end
223
+ end
224
+
225
+ # Astro's base Zod types (z.string(), z.number(), z.boolean()) do not
226
+ # coerce -- only `z.coerce.date()` does -- so a String stays
227
+ # a type error here instead of silently becoming a number or vice versa.
228
+ def coerce_string(value)
229
+ return [value, nil] if value.is_a?(String)
230
+
231
+ [nil, "#{value.inspect} is not a string"]
232
+ end
233
+
234
+ def coerce_integer(value)
235
+ return [value, nil] if value.is_a?(Integer)
236
+ # JavaScript has one number type, so Zod's `.int()` accepts `3.0`;
237
+ # YAML hands Ruby a Float for it.
238
+ return [value.to_i, nil] if value.is_a?(Float) && value.finite? && value == value.floor
239
+
240
+ [nil, "#{value.inspect} is not an integer"]
241
+ end
242
+
243
+ def coerce_float(value)
244
+ return [value.to_f, nil] if value.is_a?(Numeric)
245
+
246
+ [nil, "#{value.inspect} is not a number"]
247
+ end
248
+
249
+ def coerce_boolean(value)
250
+ return [value, nil] if value == true || value == false
251
+
252
+ [nil, "#{value.inspect} is not a boolean"]
253
+ end
254
+
255
+ # `z.coerce.date()` accepts anything `new Date()` accepts, which
256
+ # includes both `'Jul 08 2022'` and ISO strings -- Ruby's Date.parse
257
+ # covers the same ground. YAML/TOML already hand back a real Date for
258
+ # unquoted values (Andromeda::Frontmatter), so those pass through
259
+ # untouched; only a String needs parsing.
260
+ def coerce_date(value)
261
+ case value
262
+ when Date then [value.is_a?(DateTime) ? value.to_date : value, nil]
263
+ when Time then [value.to_date, nil]
264
+ when String
265
+ begin
266
+ [Date.parse(value), nil]
267
+ rescue ArgumentError, TypeError
268
+ [nil, "#{value.inspect} is not a valid date"]
269
+ end
270
+ else
271
+ [nil, "#{value.inspect} is not a valid date"]
272
+ end
273
+ end
274
+
275
+ def coerce_datetime(value)
276
+ case value
277
+ when Time then [value, nil]
278
+ when Date then [value.to_time, nil]
279
+ when String
280
+ begin
281
+ [Time.parse(value), nil]
282
+ rescue ArgumentError, TypeError
283
+ [nil, "#{value.inspect} is not a valid datetime"]
284
+ end
285
+ else
286
+ [nil, "#{value.inspect} is not a valid datetime"]
287
+ end
288
+ end
289
+
290
+ def coerce_array(attr, value, entry_path:, content_root:)
291
+ return [nil, "#{value.inspect} is not an array"] unless value.is_a?(Array)
292
+ return [value, nil] unless attr.of
293
+
294
+ element_attr = Attribute.new(name: attr.name, type: attr.of)
295
+ errors = []
296
+ elements = value.each_with_index.map do |element, index|
297
+ coerced, error = coerce(element_attr, element, entry_path: entry_path, content_root: content_root)
298
+ errors << "[#{index}] #{error}" if error
299
+ coerced
300
+ end
301
+
302
+ return [nil, errors.join("; ")] if errors.any?
303
+
304
+ [elements, nil]
305
+ end
306
+
307
+ def coerce_hash(value)
308
+ return [value, nil] if value.is_a?(Hash)
309
+
310
+ [nil, "#{value.inspect} is not a hash"]
311
+ end
312
+
313
+ def coerce_enum(attr, value)
314
+ match = attr.values.find { |allowed| allowed.to_s == value.to_s }
315
+ return [match, nil] if match
316
+
317
+ [nil, "#{value.inspect} is not one of #{attr.values.map(&:to_s).join(", ")}"]
318
+ end
319
+
320
+ # Never let a relative image path resolve outside the content
321
+ # root, however deep the `../` chain is. Existence is checked only
322
+ # after the containment check so a crafted path never learns whether
323
+ # something outside the root exists.
324
+ def coerce_image(value, entry_path:, content_root:)
325
+ return [nil, "#{value.inspect} is not a valid image path"] unless value.is_a?(String)
326
+ unless entry_path && content_root
327
+ return [nil, "image attributes require entry_path: and content_root: to resolve #{value.inspect}"]
328
+ end
329
+
330
+ absolute = File.expand_path(value, File.dirname(entry_path))
331
+ root = File.expand_path(content_root)
332
+ project = Andromeda::Assets.project_root
333
+
334
+ # Astro resolves `image()` against the entry file and lets it point
335
+ # anywhere in the project -- its own blog example keeps hero images in
336
+ # `src/assets`, outside the collection. So the boundary is the project
337
+ # (or the collection, when that lives outside it, as in tests).
338
+ inside = [project, root].any? { |boundary| absolute.start_with?("#{boundary}#{File::SEPARATOR}") }
339
+ return [nil, "#{value.inspect} resolves outside the project"] unless inside
340
+ return [nil, "#{value.inspect} does not exist"] unless File.file?(absolute)
341
+
342
+ relative = absolute.start_with?("#{root}#{File::SEPARATOR}") ? absolute.delete_prefix("#{root}#{File::SEPARATOR}") : absolute.delete_prefix("#{project}#{File::SEPARATOR}")
343
+ # The asset reference is pure path arithmetic, so a listing can link an
344
+ # image before the entry itself has been converted; copying the file is
345
+ # the conversion pipeline's job.
346
+ logical = Andromeda::Assets.logical_path(absolute, content_root: root)
347
+ [
348
+ Andromeda::Image.new(
349
+ path: absolute,
350
+ relative_path: relative,
351
+ asset: logical && Andromeda::Assets.marker_for(logical)
352
+ ),
353
+ nil
354
+ ]
355
+ end
356
+
357
+ # Astro normalizes `reference("authors")` to `{id, collection}`;
358
+ # the common case in frontmatter is a plain String id, but a
359
+ # Hash form is accepted too so already-normalized data round-trips.
360
+ # Resolving `id` against the actual collection registry happens
361
+ # elsewhere -- this only builds the
362
+ # pointer and checks it names the collection the schema declared.
363
+ def coerce_reference(attr, value)
364
+ case value
365
+ when String
366
+ [Andromeda::Reference.new(collection: attr.collection, id: value), nil]
367
+ when Hash
368
+ id = value["id"] || value[:id]
369
+ return [nil, "reference is missing id: #{value.inspect}"] unless id
370
+
371
+ collection = value["collection"] || value[:collection]
372
+ collection = collection&.to_sym
373
+ if collection && collection != attr.collection
374
+ return [nil, "reference collection #{collection.inspect} does not match declared collection #{attr.collection.inspect}"]
375
+ end
376
+
377
+ [Andromeda::Reference.new(collection: collection || attr.collection, id: id), nil]
378
+ else
379
+ [nil, "#{value.inspect} is not a valid reference"]
380
+ end
381
+ end
382
+ end
383
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Andromeda
4
+ # Ruby port of npm `github-slugger` (MIT, https://github.com/Flet/github-slugger),
5
+ # the algorithm Astro uses for heading anchor ids. Ported rather than reimplemented
6
+ # from scratch because the exact punctuation-stripping regex is the entire
7
+ # compatibility surface here: any deviation changes which headings get an
8
+ # `-1` suffix, breaking anchors that Astro-authored content links to.
9
+ #
10
+ # `STRIP` below is that package's `regex.js`, mechanically translated from
11
+ # JS's UTF-16 surrogate-pair escapes to single-codepoint `\u{...}` Ruby
12
+ # escapes (Ruby strings are UTF-8, not UTF-16, so a JS `\uD83D[\uDE00-\uDE4F]`
13
+ # pair becomes one `\u{1F600}-\u{1F64F}` range here). Verified against
14
+ # github-slugger's own `test/fixtures.json` (78 cases: ASCII, punctuation,
15
+ # camelCase, emoji, `__proto__`/`hasOwnProperty` key-injection guards,
16
+ # repetition) with zero failures before landing.
17
+ class Slugger
18
+ # rubocop:disable Layout/LineLength
19
+ STRIP = /[\u{0}-\u{1F}\u{21}-\u{2C}\u{2E}\u{2F}\u{3A}-\u{40}\u{5B}-\u{5E}\u{60}\u{7B}-\u{A9}\u{AB}-\u{B4}\u{B6}-\u{B9}\u{BB}-\u{BF}\u{D7}\u{F7}\u{2C2}-\u{2C5}\u{2D2}-\u{2DF}\u{2E5}-\u{2EB}\u{2ED}\u{2EF}-\u{2FF}\u{375}\u{378}\u{379}\u{37E}\u{380}-\u{385}\u{387}\u{38B}\u{38D}\u{3A2}\u{3F6}\u{482}\u{530}\u{557}\u{558}\u{55A}-\u{55F}\u{589}-\u{590}\u{5BE}\u{5C0}\u{5C3}\u{5C6}\u{5C8}-\u{5CF}\u{5EB}-\u{5EE}\u{5F3}-\u{60F}\u{61B}-\u{61F}\u{66A}-\u{66D}\u{6D4}\u{6DD}\u{6DE}\u{6E9}\u{6FD}\u{6FE}\u{700}-\u{70F}\u{74B}\u{74C}\u{7B2}-\u{7BF}\u{7F6}-\u{7F9}\u{7FB}\u{7FC}\u{7FE}\u{7FF}\u{82E}-\u{83F}\u{85C}-\u{85F}\u{86B}-\u{89F}\u{8B5}\u{8C8}-\u{8D2}\u{8E2}\u{964}\u{965}\u{970}\u{984}\u{98D}\u{98E}\u{991}\u{992}\u{9A9}\u{9B1}\u{9B3}-\u{9B5}\u{9BA}\u{9BB}\u{9C5}\u{9C6}\u{9C9}\u{9CA}\u{9CF}-\u{9D6}\u{9D8}-\u{9DB}\u{9DE}\u{9E4}\u{9E5}\u{9F2}-\u{9FB}\u{9FD}\u{9FF}\u{A00}\u{A04}\u{A0B}-\u{A0E}\u{A11}\u{A12}\u{A29}\u{A31}\u{A34}\u{A37}\u{A3A}\u{A3B}\u{A3D}\u{A43}-\u{A46}\u{A49}\u{A4A}\u{A4E}-\u{A50}\u{A52}-\u{A58}\u{A5D}\u{A5F}-\u{A65}\u{A76}-\u{A80}\u{A84}\u{A8E}\u{A92}\u{AA9}\u{AB1}\u{AB4}\u{ABA}\u{ABB}\u{AC6}\u{ACA}\u{ACE}\u{ACF}\u{AD1}-\u{ADF}\u{AE4}\u{AE5}\u{AF0}-\u{AF8}\u{B00}\u{B04}\u{B0D}\u{B0E}\u{B11}\u{B12}\u{B29}\u{B31}\u{B34}\u{B3A}\u{B3B}\u{B45}\u{B46}\u{B49}\u{B4A}\u{B4E}-\u{B54}\u{B58}-\u{B5B}\u{B5E}\u{B64}\u{B65}\u{B70}\u{B72}-\u{B81}\u{B84}\u{B8B}-\u{B8D}\u{B91}\u{B96}-\u{B98}\u{B9B}\u{B9D}\u{BA0}-\u{BA2}\u{BA5}-\u{BA7}\u{BAB}-\u{BAD}\u{BBA}-\u{BBD}\u{BC3}-\u{BC5}\u{BC9}\u{BCE}\u{BCF}\u{BD1}-\u{BD6}\u{BD8}-\u{BE5}\u{BF0}-\u{BFF}\u{C0D}\u{C11}\u{C29}\u{C3A}-\u{C3C}\u{C45}\u{C49}\u{C4E}-\u{C54}\u{C57}\u{C5B}-\u{C5F}\u{C64}\u{C65}\u{C70}-\u{C7F}\u{C84}\u{C8D}\u{C91}\u{CA9}\u{CB4}\u{CBA}\u{CBB}\u{CC5}\u{CC9}\u{CCE}-\u{CD4}\u{CD7}-\u{CDD}\u{CDF}\u{CE4}\u{CE5}\u{CF0}\u{CF3}-\u{CFF}\u{D0D}\u{D11}\u{D45}\u{D49}\u{D4F}-\u{D53}\u{D58}-\u{D5E}\u{D64}\u{D65}\u{D70}-\u{D79}\u{D80}\u{D84}\u{D97}-\u{D99}\u{DB2}\u{DBC}\u{DBE}\u{DBF}\u{DC7}-\u{DC9}\u{DCB}-\u{DCE}\u{DD5}\u{DD7}\u{DE0}-\u{DE5}\u{DF0}\u{DF1}\u{DF4}-\u{E00}\u{E3B}-\u{E3F}\u{E4F}\u{E5A}-\u{E80}\u{E83}\u{E85}\u{E8B}\u{EA4}\u{EA6}\u{EBE}\u{EBF}\u{EC5}\u{EC7}\u{ECE}\u{ECF}\u{EDA}\u{EDB}\u{EE0}-\u{EFF}\u{F01}-\u{F17}\u{F1A}-\u{F1F}\u{F2A}-\u{F34}\u{F36}\u{F38}\u{F3A}-\u{F3D}\u{F48}\u{F6D}-\u{F70}\u{F85}\u{F98}\u{FBD}-\u{FC5}\u{FC7}-\u{FFF}\u{104A}-\u{104F}\u{109E}\u{109F}\u{10C6}\u{10C8}-\u{10CC}\u{10CE}\u{10CF}\u{10FB}\u{1249}\u{124E}\u{124F}\u{1257}\u{1259}\u{125E}\u{125F}\u{1289}\u{128E}\u{128F}\u{12B1}\u{12B6}\u{12B7}\u{12BF}\u{12C1}\u{12C6}\u{12C7}\u{12D7}\u{1311}\u{1316}\u{1317}\u{135B}\u{135C}\u{1360}-\u{137F}\u{1390}-\u{139F}\u{13F6}\u{13F7}\u{13FE}-\u{1400}\u{166D}\u{166E}\u{1680}\u{169B}-\u{169F}\u{16EB}-\u{16ED}\u{16F9}-\u{16FF}\u{170D}\u{1715}-\u{171F}\u{1735}-\u{173F}\u{1754}-\u{175F}\u{176D}\u{1771}\u{1774}-\u{177F}\u{17D4}-\u{17D6}\u{17D8}-\u{17DB}\u{17DE}\u{17DF}\u{17EA}-\u{180A}\u{180E}\u{180F}\u{181A}-\u{181F}\u{1879}-\u{187F}\u{18AB}-\u{18AF}\u{18F6}-\u{18FF}\u{191F}\u{192C}-\u{192F}\u{193C}-\u{1945}\u{196E}\u{196F}\u{1975}-\u{197F}\u{19AC}-\u{19AF}\u{19CA}-\u{19CF}\u{19DA}-\u{19FF}\u{1A1C}-\u{1A1F}\u{1A5F}\u{1A7D}\u{1A7E}\u{1A8A}-\u{1A8F}\u{1A9A}-\u{1AA6}\u{1AA8}-\u{1AAF}\u{1AC1}-\u{1AFF}\u{1B4C}-\u{1B4F}\u{1B5A}-\u{1B6A}\u{1B74}-\u{1B7F}\u{1BF4}-\u{1BFF}\u{1C38}-\u{1C3F}\u{1C4A}-\u{1C4C}\u{1C7E}\u{1C7F}\u{1C89}-\u{1C8F}\u{1CBB}\u{1CBC}\u{1CC0}-\u{1CCF}\u{1CD3}\u{1CFB}-\u{1CFF}\u{1DFA}\u{1F16}\u{1F17}\u{1F1E}\u{1F1F}\u{1F46}\u{1F47}\u{1F4E}\u{1F4F}\u{1F58}\u{1F5A}\u{1F5C}\u{1F5E}\u{1F7E}\u{1F7F}\u{1FB5}\u{1FBD}\u{1FBF}-\u{1FC1}\u{1FC5}\u{1FCD}-\u{1FCF}\u{1FD4}\u{1FD5}\u{1FDC}-\u{1FDF}\u{1FED}-\u{1FF1}\u{1FF5}\u{1FFD}-\u{203E}\u{2041}-\u{2053}\u{2055}-\u{2070}\u{2072}-\u{207E}\u{2080}-\u{208F}\u{209D}-\u{20CF}\u{20F1}-\u{2101}\u{2103}-\u{2106}\u{2108}\u{2109}\u{2114}\u{2116}-\u{2118}\u{211E}-\u{2123}\u{2125}\u{2127}\u{2129}\u{212E}\u{213A}\u{213B}\u{2140}-\u{2144}\u{214A}-\u{214D}\u{214F}-\u{215F}\u{2189}-\u{24B5}\u{24EA}-\u{2BFF}\u{2C2F}\u{2C5F}\u{2CE5}-\u{2CEA}\u{2CF4}-\u{2CFF}\u{2D26}\u{2D28}-\u{2D2C}\u{2D2E}\u{2D2F}\u{2D68}-\u{2D6E}\u{2D70}-\u{2D7E}\u{2D97}-\u{2D9F}\u{2DA7}\u{2DAF}\u{2DB7}\u{2DBF}\u{2DC7}\u{2DCF}\u{2DD7}\u{2DDF}\u{2E00}-\u{2E2E}\u{2E30}-\u{3004}\u{3008}-\u{3020}\u{3030}\u{3036}\u{3037}\u{303D}-\u{3040}\u{3097}\u{3098}\u{309B}\u{309C}\u{30A0}\u{30FB}\u{3100}-\u{3104}\u{3130}\u{318F}-\u{319F}\u{31C0}-\u{31EF}\u{3200}-\u{33FF}\u{4DC0}-\u{4DFF}\u{9FFD}-\u{9FFF}\u{A48D}-\u{A4CF}\u{A4FE}\u{A4FF}\u{A60D}-\u{A60F}\u{A62C}-\u{A63F}\u{A673}\u{A67E}\u{A6F2}-\u{A716}\u{A720}\u{A721}\u{A789}\u{A78A}\u{A7C0}\u{A7C1}\u{A7CB}-\u{A7F4}\u{A828}-\u{A82B}\u{A82D}-\u{A83F}\u{A874}-\u{A87F}\u{A8C6}-\u{A8CF}\u{A8DA}-\u{A8DF}\u{A8F8}-\u{A8FA}\u{A8FC}\u{A92E}\u{A92F}\u{A954}-\u{A95F}\u{A97D}-\u{A97F}\u{A9C1}-\u{A9CE}\u{A9DA}-\u{A9DF}\u{A9FF}\u{AA37}-\u{AA3F}\u{AA4E}\u{AA4F}\u{AA5A}-\u{AA5F}\u{AA77}-\u{AA79}\u{AAC3}-\u{AADA}\u{AADE}\u{AADF}\u{AAF0}\u{AAF1}\u{AAF7}-\u{AB00}\u{AB07}\u{AB08}\u{AB0F}\u{AB10}\u{AB17}-\u{AB1F}\u{AB27}\u{AB2F}\u{AB5B}\u{AB6A}-\u{AB6F}\u{ABEB}\u{ABEE}\u{ABEF}\u{ABFA}-\u{ABFF}\u{D7A4}-\u{D7AF}\u{D7C7}-\u{D7CA}\u{D7FC}-\u{D7FF}\u{E000}-\u{F8FF}\u{FA6E}\u{FA6F}\u{FADA}-\u{FAFF}\u{FB07}-\u{FB12}\u{FB18}-\u{FB1C}\u{FB29}\u{FB37}\u{FB3D}\u{FB3F}\u{FB42}\u{FB45}\u{FBB2}-\u{FBD2}\u{FD3E}-\u{FD4F}\u{FD90}\u{FD91}\u{FDC8}-\u{FDEF}\u{FDFC}-\u{FDFF}\u{FE10}-\u{FE1F}\u{FE30}-\u{FE32}\u{FE35}-\u{FE4C}\u{FE50}-\u{FE6F}\u{FE75}\u{FEFD}-\u{FF0F}\u{FF1A}-\u{FF20}\u{FF3B}-\u{FF3E}\u{FF40}\u{FF5B}-\u{FF65}\u{FFBF}-\u{FFC1}\u{FFC8}\u{FFC9}\u{FFD0}\u{FFD1}\u{FFD8}\u{FFD9}\u{FFDD}-\u{FFFF}\u{1000C}\u{10027}\u{1003B}\u{1003E}\u{1004E}\u{1004F}\u{1005E}-\u{1007F}\u{100FB}-\u{1013F}\u{10175}-\u{101FC}\u{101FE}-\u{1027F}\u{1029D}-\u{1029F}\u{102D1}-\u{102DF}\u{102E1}-\u{102FF}\u{10320}-\u{1032C}\u{1034B}-\u{1034F}\u{1037B}-\u{1037F}\u{1039E}\u{1039F}\u{103C4}-\u{103C7}\u{103D0}\u{103D6}-\u{103FF}\u{1049E}\u{1049F}\u{104AA}-\u{104AF}\u{104D4}-\u{104D7}\u{104FC}-\u{104FF}\u{10528}-\u{1052F}\u{10564}-\u{105FF}\u{10737}-\u{1073F}\u{10756}-\u{1075F}\u{10768}-\u{107FF}\u{10806}\u{10807}\u{10809}\u{10836}\u{10839}-\u{1083B}\u{1083D}\u{1083E}\u{10856}-\u{1085F}\u{10877}-\u{1087F}\u{1089F}-\u{108DF}\u{108F3}\u{108F6}-\u{108FF}\u{10916}-\u{1091F}\u{1093A}-\u{1097F}\u{109B8}-\u{109BD}\u{109C0}-\u{109FF}\u{10A04}\u{10A07}-\u{10A0B}\u{10A14}\u{10A18}\u{10A36}\u{10A37}\u{10A3B}-\u{10A3E}\u{10A40}-\u{10A5F}\u{10A7D}-\u{10A7F}\u{10A9D}-\u{10ABF}\u{10AC8}\u{10AE7}-\u{10AFF}\u{10B36}-\u{10B3F}\u{10B56}-\u{10B5F}\u{10B73}-\u{10B7F}\u{10B92}-\u{10BFF}\u{10C49}-\u{10C7F}\u{10CB3}-\u{10CBF}\u{10CF3}-\u{10CFF}\u{10D28}-\u{10D2F}\u{10D3A}-\u{10E7F}\u{10EAA}\u{10EAD}-\u{10EAF}\u{10EB2}-\u{10EFF}\u{10F1D}-\u{10F26}\u{10F28}-\u{10F2F}\u{10F51}-\u{10FAF}\u{10FC5}-\u{10FDF}\u{10FF7}-\u{10FFF}\u{11047}-\u{11065}\u{11070}-\u{1107E}\u{110BB}-\u{110CF}\u{110E9}-\u{110EF}\u{110FA}-\u{110FF}\u{11135}\u{11140}-\u{11143}\u{11148}-\u{1114F}\u{11174}\u{11175}\u{11177}-\u{1117F}\u{111C5}-\u{111C8}\u{111CD}\u{111DB}\u{111DD}-\u{111FF}\u{11212}\u{11238}-\u{1123D}\u{1123F}-\u{1127F}\u{11287}\u{11289}\u{1128E}\u{1129E}\u{112A9}-\u{112AF}\u{112EB}-\u{112EF}\u{112FA}-\u{112FF}\u{11304}\u{1130D}\u{1130E}\u{11311}\u{11312}\u{11329}\u{11331}\u{11334}\u{1133A}\u{11345}\u{11346}\u{11349}\u{1134A}\u{1134E}\u{1134F}\u{11351}-\u{11356}\u{11358}-\u{1135C}\u{11364}\u{11365}\u{1136D}-\u{1136F}\u{11375}-\u{113FF}\u{1144B}-\u{1144F}\u{1145A}-\u{1145D}\u{11462}-\u{1147F}\u{114C6}\u{114C8}-\u{114CF}\u{114DA}-\u{1157F}\u{115B6}\u{115B7}\u{115C1}-\u{115D7}\u{115DE}-\u{115FF}\u{11641}-\u{11643}\u{11645}-\u{1164F}\u{1165A}-\u{1167F}\u{116B9}-\u{116BF}\u{116CA}-\u{116FF}\u{1171B}\u{1171C}\u{1172C}-\u{1172F}\u{1173A}-\u{117FF}\u{1183B}-\u{1189F}\u{118EA}-\u{118FE}\u{11907}\u{11908}\u{1190A}\u{1190B}\u{11914}\u{11917}\u{11936}\u{11939}\u{1193A}\u{11944}-\u{1194F}\u{1195A}-\u{1199F}\u{119A8}\u{119A9}\u{119D8}\u{119D9}\u{119E2}\u{119E5}-\u{119FF}\u{11A3F}-\u{11A46}\u{11A48}-\u{11A4F}\u{11A9A}-\u{11A9C}\u{11A9E}-\u{11ABF}\u{11AF9}-\u{11BFF}\u{11C09}\u{11C37}\u{11C41}-\u{11C4F}\u{11C5A}-\u{11C71}\u{11C90}\u{11C91}\u{11CA8}\u{11CB7}-\u{11CFF}\u{11D07}\u{11D0A}\u{11D37}-\u{11D39}\u{11D3B}\u{11D3E}\u{11D48}-\u{11D4F}\u{11D5A}-\u{11D5F}\u{11D66}\u{11D69}\u{11D8F}\u{11D92}\u{11D99}-\u{11D9F}\u{11DAA}-\u{11EDF}\u{11EF7}-\u{11FAF}\u{11FB1}-\u{11FFF}\u{1239A}-\u{123FF}\u{1246F}-\u{1247F}\u{12544}-\u{127FF}\u{12800}-\u{12BFF}\u{12C00}-\u{12FFF}\u{13800}-\u{143FF}\u{14800}-\u{167FF}\u{19000}-\u{1AFFF}\u{1B400}-\u{1B7FF}\u{1B800}-\u{1BBFF}\u{1C000}-\u{1CFFF}\u{1DC00}-\u{1DFFF}\u{1E400}-\u{1E7FF}\u{1F400}-\u{1F7FF}\u{1FC00}-\u{1FFFF}\u{2EC00}-\u{2F7FF}\u{2FC00}-\u{2FFFF}\u{31400}-\u{DFFFF}\u{E0400}-\u{10FFFF}\u{1342F}-\u{137FF}\u{14647}-\u{147FF}\u{16A39}-\u{16A3F}\u{16A5F}\u{16A6A}-\u{16ACF}\u{16AEE}\u{16AEF}\u{16AF5}-\u{16AFF}\u{16B37}-\u{16B3F}\u{16B44}-\u{16B4F}\u{16B5A}-\u{16B62}\u{16B78}-\u{16B7C}\u{16B90}-\u{16BFF}\u{16C00}-\u{16E3F}\u{16E80}-\u{16EFF}\u{16F4B}-\u{16F4E}\u{16F88}-\u{16F8E}\u{16FA0}-\u{16FDF}\u{16FE2}\u{16FE5}-\u{16FEF}\u{16FF2}-\u{16FFF}\u{187F8}-\u{187FF}\u{18CD6}-\u{18CFF}\u{18D09}-\u{18FFF}\u{1B11F}-\u{1B14F}\u{1B153}-\u{1B163}\u{1B168}-\u{1B16F}\u{1B2FC}-\u{1B3FF}\u{1BC6B}-\u{1BC6F}\u{1BC7D}-\u{1BC7F}\u{1BC89}-\u{1BC8F}\u{1BC9A}-\u{1BC9C}\u{1BC9F}-\u{1BFFF}\u{1D000}-\u{1D164}\u{1D16A}-\u{1D16C}\u{1D173}-\u{1D17A}\u{1D183}\u{1D184}\u{1D18C}-\u{1D1A9}\u{1D1AE}-\u{1D241}\u{1D245}-\u{1D3FF}\u{1D455}\u{1D49D}\u{1D4A0}\u{1D4A1}\u{1D4A3}\u{1D4A4}\u{1D4A7}\u{1D4A8}\u{1D4AD}\u{1D4BA}\u{1D4BC}\u{1D4C4}\u{1D506}\u{1D50B}\u{1D50C}\u{1D515}\u{1D51D}\u{1D53A}\u{1D53F}\u{1D545}\u{1D547}-\u{1D549}\u{1D551}\u{1D6A6}\u{1D6A7}\u{1D6C1}\u{1D6DB}\u{1D6FB}\u{1D715}\u{1D735}\u{1D74F}\u{1D76F}\u{1D789}\u{1D7A9}\u{1D7C3}\u{1D7CC}\u{1D7CD}\u{1D800}-\u{1D9FF}\u{1DA37}-\u{1DA3A}\u{1DA6D}-\u{1DA74}\u{1DA76}-\u{1DA83}\u{1DA85}-\u{1DA9A}\u{1DAA0}\u{1DAB0}-\u{1DBFF}\u{1E007}\u{1E019}\u{1E01A}\u{1E022}\u{1E025}\u{1E02B}-\u{1E0FF}\u{1E12D}-\u{1E12F}\u{1E13E}\u{1E13F}\u{1E14A}-\u{1E14D}\u{1E14F}-\u{1E2BF}\u{1E2FA}-\u{1E3FF}\u{1E8C5}-\u{1E8CF}\u{1E8D7}-\u{1E8FF}\u{1E94C}-\u{1E94F}\u{1E95A}-\u{1EBFF}\u{1EC00}-\u{1EDFF}\u{1EE04}\u{1EE20}\u{1EE23}\u{1EE25}\u{1EE26}\u{1EE28}\u{1EE33}\u{1EE38}\u{1EE3A}\u{1EE3C}-\u{1EE41}\u{1EE43}-\u{1EE46}\u{1EE48}\u{1EE4A}\u{1EE4C}\u{1EE50}\u{1EE53}\u{1EE55}\u{1EE56}\u{1EE58}\u{1EE5A}\u{1EE5C}\u{1EE5E}\u{1EE60}\u{1EE63}\u{1EE65}\u{1EE66}\u{1EE6B}\u{1EE73}\u{1EE78}\u{1EE7D}\u{1EE7F}\u{1EE8A}\u{1EE9C}-\u{1EEA0}\u{1EEA4}\u{1EEAA}\u{1EEBC}-\u{1EFFF}\u{1F000}-\u{1F12F}\u{1F14A}-\u{1F14F}\u{1F16A}-\u{1F16F}\u{1F18A}-\u{1F3FF}\u{1F800}-\u{1FBEF}\u{1FBFA}-\u{1FBFF}\u{2A6DE}-\u{2A6FF}\u{2B735}-\u{2B73F}\u{2B81E}\u{2B81F}\u{2CEA2}-\u{2CEAF}\u{2EBE1}-\u{2EBFF}\u{2FA1E}-\u{2FBFF}\u{3134B}-\u{313FF}\u{E0000}-\u{E00FF}\u{E01F0}-\u{E03FF}]/.freeze
20
+ # rubocop:enable Layout/LineLength
21
+
22
+ def initialize
23
+ reset
24
+ end
25
+
26
+ # Returns a slug unique within this instance's lifetime: a repeat of the
27
+ # same heading text gets `-1`, `-2`, ... appended (matching remark's
28
+ # rehype-collect-headings, which keeps one Slugger per document).
29
+ #
30
+ # @param text [String] heading text (already flattened to plain text --
31
+ # see Renderer::Headings for how child text nodes are joined).
32
+ # @return [String]
33
+ def slug(text)
34
+ original = self.class.slugify(text)
35
+ candidate = original
36
+
37
+ # A `while`, not an `if`: the candidate can itself collide with an
38
+ # earlier *explicit* occurrence (e.g. one heading literally titled
39
+ # "Foo 1" followed by two headings titled "Foo"), so bumping once is
40
+ # not always enough. This mirrors upstream's `BananaSlug#slug` loop.
41
+ while @occurrences.key?(candidate)
42
+ @occurrences[original] += 1
43
+ candidate = "#{original}-#{@occurrences[original]}"
44
+ end
45
+
46
+ @occurrences[candidate] = 0
47
+ candidate
48
+ end
49
+
50
+ # Forgets all previously generated slugs (upstream's `BananaSlug#reset`).
51
+ def reset
52
+ @occurrences = {}
53
+ end
54
+
55
+ # Stateless slugify, matching upstream's exported `slug()` function:
56
+ # repeated calls with the same input return the same value (no `-1`
57
+ # suffixing). Kept public because callers occasionally want to check
58
+ # what an id *would* be without touching an instance's dedup state.
59
+ #
60
+ # @param text [String, nil]
61
+ # @return [String]
62
+ def self.slugify(text)
63
+ return "" unless text.is_a?(String)
64
+
65
+ text.downcase.gsub(STRIP, "").gsub(" ", "-")
66
+ end
67
+ end
68
+ end