ro 4.4.0 → 5.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.
Files changed (161) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +42 -16
  3. data/MIGRATION.md +320 -0
  4. data/README.md +31 -19
  5. data/a.yml +60 -0
  6. data/bin/ro +10 -0
  7. data/lib/ro/_lib.rb +1 -1
  8. data/lib/ro/asset.rb +48 -6
  9. data/lib/ro/collection.rb +51 -13
  10. data/lib/ro/migrator.rb +285 -0
  11. data/lib/ro/node.rb +53 -13
  12. data/lib/ro/root.rb +75 -1
  13. data/lib/ro/script/migrate.rb +204 -0
  14. data/lib/ro/script/server.rb +1 -1
  15. data/lib/ro.rb +1 -0
  16. data/public/api/ro/index-1.json +82 -148
  17. data/public/api/ro/index.json +82 -148
  18. data/public/api/ro/nerd/fastest-possible-embeddings/index.json +7 -8
  19. data/public/api/ro/nerd/ima/index.json +3 -4
  20. data/public/api/ro/nerd/index/index.json +5 -6
  21. data/public/api/ro/nerd/index-1.json +15 -18
  22. data/public/api/ro/nerd/index.json +15 -18
  23. data/public/api/ro/pages/contact/index.json +4 -5
  24. data/public/api/ro/pages/cv/index.json +3 -4
  25. data/public/api/ro/pages/disco/index.json +9 -10
  26. data/public/api/ro/pages/index/index.json +2 -3
  27. data/public/api/ro/pages/index-1.json +25 -82
  28. data/public/api/ro/pages/index.json +25 -82
  29. data/public/api/ro/pages/jess/index.json +4 -5
  30. data/public/api/ro/pages/now/index.json +3 -4
  31. data/public/api/ro/posts/almost-died-in-an-ice-cave/index.json +21 -22
  32. data/public/api/ro/posts/facebook-and-global-extremism/index.json +8 -9
  33. data/public/api/ro/posts/index-1.json +42 -48
  34. data/public/api/ro/posts/index.json +42 -48
  35. data/public/api/ro/posts/lemmings-considered-harmful/index.json +3 -4
  36. data/public/api/ro/posts/lost-in-the-desert/index.json +3 -4
  37. data/public/api/ro/posts/mission/index.json +3 -4
  38. data/public/api/ro/posts/return-your-laptop/index.json +4 -5
  39. data/ro.gemspec +247 -18
  40. data/specs/001-simplify-asset-structure/IMPLEMENTATION_SUMMARY.md +212 -0
  41. data/specs/001-simplify-asset-structure/checklists/requirements.md +36 -0
  42. data/specs/001-simplify-asset-structure/contracts/collection_api.md +407 -0
  43. data/specs/001-simplify-asset-structure/contracts/migrator_api.md +461 -0
  44. data/specs/001-simplify-asset-structure/contracts/node_api.md +294 -0
  45. data/specs/001-simplify-asset-structure/data-model.md +381 -0
  46. data/specs/001-simplify-asset-structure/plan.md +90 -0
  47. data/specs/001-simplify-asset-structure/quickstart.md +575 -0
  48. data/specs/001-simplify-asset-structure/research.md +333 -0
  49. data/specs/001-simplify-asset-structure/spec.md +127 -0
  50. data/specs/001-simplify-asset-structure/tasks.md +349 -0
  51. data/test/fixtures/new_structure/mixed/test-json.json +5 -0
  52. data/test/fixtures/new_structure/mixed/test-yaml.yml +3 -0
  53. data/test/fixtures/new_structure/posts/metadata-only.yml +7 -0
  54. data/test/fixtures/new_structure/posts/nested-test/assets/subdirectory/image.png +2 -0
  55. data/test/fixtures/new_structure/posts/nested-test.yml +7 -0
  56. data/test/fixtures/new_structure/posts/sample-post/assets/body.md +5 -0
  57. data/test/fixtures/new_structure/posts/sample-post/assets/image.jpg +2 -0
  58. data/test/fixtures/new_structure/posts/sample-post.yml +7 -0
  59. data/test/fixtures/old_structure/posts/assets-only/assets/test.txt +1 -0
  60. data/test/fixtures/old_structure/posts/sample-post/assets/body.md +5 -0
  61. data/test/fixtures/old_structure/posts/sample-post/assets/image.jpg +2 -0
  62. data/test/fixtures/old_structure/posts/sample-post/attributes.yml +2 -0
  63. data/test/integration/ro_integration_test.rb +165 -0
  64. data/test/test_helper.rb +149 -0
  65. data/test/tmp/migration_test_1760746513.backup.20251018001513/migration_test_1760746513/posts/sample-post/assets/image.jpg +2 -0
  66. data/test/tmp/migration_test_1760746513.backup.20251018001513/migration_test_1760746513/posts/sample-post/attributes.yml +7 -0
  67. data/test/tmp/migration_test_1760746513.backup.20251018001513/migration_test_1760746513/posts/sample-post/body.md +5 -0
  68. data/test/tmp/migration_test_1760746513.backup.20251018001513/posts/sample-post/assets/image.jpg +2 -0
  69. data/test/tmp/migration_test_1760746513.backup.20251018001513/posts/sample-post/attributes.yml +7 -0
  70. data/test/tmp/migration_test_1760746513.backup.20251018001513/posts/sample-post/body.md +5 -0
  71. data/test/tmp/migration_test_1760746556.backup.20251018001556/migration_test_1760746556/posts/sample-post/assets/image.jpg +2 -0
  72. data/test/tmp/migration_test_1760746556.backup.20251018001556/migration_test_1760746556/posts/sample-post/attributes.yml +7 -0
  73. data/test/tmp/migration_test_1760746556.backup.20251018001556/migration_test_1760746556/posts/sample-post/body.md +5 -0
  74. data/test/tmp/migration_test_1760746556.backup.20251018001556/posts/sample-post/assets/image.jpg +2 -0
  75. data/test/tmp/migration_test_1760746556.backup.20251018001556/posts/sample-post/attributes.yml +7 -0
  76. data/test/tmp/migration_test_1760746556.backup.20251018001556/posts/sample-post/body.md +5 -0
  77. data/test/tmp/migration_test_1760755248.backup.20251018024048/migration_test_1760755248/posts/sample-post/assets/image.jpg +2 -0
  78. data/test/tmp/migration_test_1760755248.backup.20251018024048/migration_test_1760755248/posts/sample-post/attributes.yml +7 -0
  79. data/test/tmp/migration_test_1760755248.backup.20251018024048/migration_test_1760755248/posts/sample-post/body.md +5 -0
  80. data/test/tmp/migration_test_1760755248.backup.20251018024048/posts/sample-post/assets/image.jpg +2 -0
  81. data/test/tmp/migration_test_1760755248.backup.20251018024048/posts/sample-post/attributes.yml +7 -0
  82. data/test/tmp/migration_test_1760755248.backup.20251018024048/posts/sample-post/body.md +5 -0
  83. data/test/tmp/migration_test_1760758803.backup.20251018034003/migration_test_1760758803/posts/sample-post/body.md +5 -0
  84. data/test/tmp/migration_test_1760758803.backup.20251018034003/migration_test_1760758803/posts/sample-post/image.jpg +2 -0
  85. data/test/tmp/migration_test_1760758803.backup.20251018034003/migration_test_1760758803/posts/sample-post.yml +7 -0
  86. data/test/tmp/migration_test_1760758803.backup.20251018034003/posts/sample-post/body.md +5 -0
  87. data/test/tmp/migration_test_1760758803.backup.20251018034003/posts/sample-post/image.jpg +2 -0
  88. data/test/tmp/migration_test_1760758803.backup.20251018034003/posts/sample-post.yml +7 -0
  89. data/test/tmp/migration_test_1760758869.backup.20251018034109/migration_test_1760758869/posts/sample-post/assets/body.md +5 -0
  90. data/test/tmp/migration_test_1760758869.backup.20251018034109/migration_test_1760758869/posts/sample-post/assets/image.jpg +2 -0
  91. data/test/tmp/migration_test_1760758869.backup.20251018034109/migration_test_1760758869/posts/sample-post/attributes.yml +2 -0
  92. data/test/tmp/migration_test_1760758869.backup.20251018034109/posts/sample-post/assets/body.md +5 -0
  93. data/test/tmp/migration_test_1760758869.backup.20251018034109/posts/sample-post/assets/image.jpg +2 -0
  94. data/test/tmp/migration_test_1760758869.backup.20251018034109/posts/sample-post/attributes.yml +2 -0
  95. data/test/tmp/migration_test_1760758920.backup.20251018034200/migration_test_1760758920/posts/sample-post/assets/body.md +5 -0
  96. data/test/tmp/migration_test_1760758920.backup.20251018034200/migration_test_1760758920/posts/sample-post/assets/image.jpg +2 -0
  97. data/test/tmp/migration_test_1760758920.backup.20251018034200/migration_test_1760758920/posts/sample-post/attributes.yml +2 -0
  98. data/test/tmp/migration_test_1760758920.backup.20251018034200/posts/sample-post/assets/body.md +5 -0
  99. data/test/tmp/migration_test_1760758920.backup.20251018034200/posts/sample-post/assets/image.jpg +2 -0
  100. data/test/tmp/migration_test_1760758920.backup.20251018034200/posts/sample-post/attributes.yml +2 -0
  101. data/test/tmp/migration_test_1760824728.backup.20251018215848/migration_test_1760824728/posts/assets-only/assets/test.txt +1 -0
  102. data/test/tmp/migration_test_1760824728.backup.20251018215848/migration_test_1760824728/posts/sample-post/assets/body.md +5 -0
  103. data/test/tmp/migration_test_1760824728.backup.20251018215848/migration_test_1760824728/posts/sample-post/assets/image.jpg +2 -0
  104. data/test/tmp/migration_test_1760824728.backup.20251018215848/migration_test_1760824728/posts/sample-post/attributes.yml +2 -0
  105. data/test/tmp/migration_test_1760824728.backup.20251018215848/posts/assets-only/assets/test.txt +1 -0
  106. data/test/tmp/migration_test_1760824728.backup.20251018215848/posts/sample-post/assets/body.md +5 -0
  107. data/test/tmp/migration_test_1760824728.backup.20251018215848/posts/sample-post/assets/image.jpg +2 -0
  108. data/test/tmp/migration_test_1760824728.backup.20251018215848/posts/sample-post/attributes.yml +2 -0
  109. data/test/tmp/migration_test_1760844153.backup.20251019032233/migration_test_1760844153/posts/assets-only/assets/test.txt +1 -0
  110. data/test/tmp/migration_test_1760844153.backup.20251019032233/migration_test_1760844153/posts/sample-post/assets/body.md +5 -0
  111. data/test/tmp/migration_test_1760844153.backup.20251019032233/migration_test_1760844153/posts/sample-post/assets/image.jpg +2 -0
  112. data/test/tmp/migration_test_1760844153.backup.20251019032233/migration_test_1760844153/posts/sample-post/attributes.yml +2 -0
  113. data/test/tmp/migration_test_1760844153.backup.20251019032233/posts/assets-only/assets/test.txt +1 -0
  114. data/test/tmp/migration_test_1760844153.backup.20251019032233/posts/sample-post/assets/body.md +5 -0
  115. data/test/tmp/migration_test_1760844153.backup.20251019032233/posts/sample-post/assets/image.jpg +2 -0
  116. data/test/tmp/migration_test_1760844153.backup.20251019032233/posts/sample-post/attributes.yml +2 -0
  117. data/test/tmp/migration_test_1760940939.backup.20251020061539/migration_test_1760940939/posts/assets-only/assets/test.txt +1 -0
  118. data/test/tmp/migration_test_1760940939.backup.20251020061539/migration_test_1760940939/posts/sample-post/assets/body.md +5 -0
  119. data/test/tmp/migration_test_1760940939.backup.20251020061539/migration_test_1760940939/posts/sample-post/assets/image.jpg +2 -0
  120. data/test/tmp/migration_test_1760940939.backup.20251020061539/migration_test_1760940939/posts/sample-post/attributes.yml +2 -0
  121. data/test/tmp/migration_test_1760940939.backup.20251020061539/posts/assets-only/assets/test.txt +1 -0
  122. data/test/tmp/migration_test_1760940939.backup.20251020061539/posts/sample-post/assets/body.md +5 -0
  123. data/test/tmp/migration_test_1760940939.backup.20251020061539/posts/sample-post/assets/image.jpg +2 -0
  124. data/test/tmp/migration_test_1760940939.backup.20251020061539/posts/sample-post/attributes.yml +2 -0
  125. data/test/tmp/migration_test_1760941048.backup.20251020061728/migration_test_1760941048/posts/assets-only/assets/test.txt +1 -0
  126. data/test/tmp/migration_test_1760941048.backup.20251020061728/migration_test_1760941048/posts/sample-post/assets/body.md +5 -0
  127. data/test/tmp/migration_test_1760941048.backup.20251020061728/migration_test_1760941048/posts/sample-post/assets/image.jpg +2 -0
  128. data/test/tmp/migration_test_1760941048.backup.20251020061728/migration_test_1760941048/posts/sample-post/attributes.yml +2 -0
  129. data/test/tmp/migration_test_1760941048.backup.20251020061728/posts/assets-only/assets/test.txt +1 -0
  130. data/test/tmp/migration_test_1760941048.backup.20251020061728/posts/sample-post/assets/body.md +5 -0
  131. data/test/tmp/migration_test_1760941048.backup.20251020061728/posts/sample-post/assets/image.jpg +2 -0
  132. data/test/tmp/migration_test_1760941048.backup.20251020061728/posts/sample-post/attributes.yml +2 -0
  133. data/test/tmp/new_structure_test_1760746452/mixed/test-json.json +5 -0
  134. data/test/tmp/new_structure_test_1760746452/mixed/test-yaml.yml +3 -0
  135. data/test/tmp/new_structure_test_1760746452/posts/metadata-only.yml +7 -0
  136. data/test/tmp/new_structure_test_1760746452/posts/nested-test/subdirectory/image.png +2 -0
  137. data/test/tmp/new_structure_test_1760746452/posts/nested-test.yml +7 -0
  138. data/test/tmp/new_structure_test_1760746452/posts/sample-post/body.md +5 -0
  139. data/test/tmp/new_structure_test_1760746452/posts/sample-post/image.jpg +2 -0
  140. data/test/tmp/new_structure_test_1760746452/posts/sample-post.yml +7 -0
  141. data/test/unit/asset_test.rb +90 -0
  142. data/test/unit/collection_test.rb +127 -0
  143. data/test/unit/migrator_test.rb +209 -0
  144. data/test/unit/node_test.rb +138 -0
  145. metadata +127 -19
  146. data/public/api/ro/pages/about/index.json +0 -60
  147. /data/public/ro/nerd/{fastest-possible-embeddings/attributes.yml → fastest-possible-embeddings.yml} +0 -0
  148. /data/public/ro/nerd/{ima/attributes.yml → ima.yml} +0 -0
  149. /data/public/ro/nerd/{index/attributes.yml → index.yml} +0 -0
  150. /data/public/ro/pages/{contact/attributes.yml → contact.yml} +0 -0
  151. /data/public/ro/pages/{cv/attributes.yml → cv.yml} +0 -0
  152. /data/public/ro/pages/{disco/attributes.yml → disco.yml} +0 -0
  153. /data/public/ro/pages/{index/attributes.yml → index.yml} +0 -0
  154. /data/public/ro/pages/{jess/attributes.yml → jess.yml} +0 -0
  155. /data/public/ro/pages/{now/attributes.yml → now.yml} +0 -0
  156. /data/public/ro/posts/{almost-died-in-an-ice-cave/attributes.yml → almost-died-in-an-ice-cave.yml} +0 -0
  157. /data/public/ro/posts/{facebook-and-global-extremism/attributes.yml → facebook-and-global-extremism.yml} +0 -0
  158. /data/public/ro/posts/{lemmings-considered-harmful/attributes.yml → lemmings-considered-harmful.yml} +0 -0
  159. /data/public/ro/posts/{lost-in-the-desert/attributes.yml → lost-in-the-desert.yml} +0 -0
  160. /data/public/ro/posts/{mission/attributes.yml → mission.yml} +0 -0
  161. /data/public/ro/posts/{return-your-laptop/attributes.yml → return-your-laptop.yml} +0 -0
data/lib/ro/collection.rb CHANGED
@@ -34,6 +34,20 @@ module Ro
34
34
  Node.new(path)
35
35
  end
36
36
 
37
+ # T021: Scan for metadata files in new structure format
38
+ def metadata_files
39
+ extensions = %w[yml yaml json toml]
40
+ files = []
41
+
42
+ extensions.each do |ext|
43
+ @path.glob("*.#{ext}").each do |file|
44
+ files << file if file.file?
45
+ end
46
+ end
47
+
48
+ files.sort
49
+ end
50
+
37
51
  def subdirectories(...)
38
52
  @path.subdirectories(...)
39
53
  end
@@ -42,28 +56,33 @@ module Ro
42
56
  @path.subdirectory_for(name)
43
57
  end
44
58
 
59
+ # T020: Modified to discover nodes from metadata files (new structure)
45
60
  def each(offset:nil, limit:nil, &block)
46
- accum = []
61
+ # Return enumerator if no block given and no offset/limit
62
+ return to_enum(:each, offset: offset, limit: limit) unless block_given?
63
+
64
+ # Use metadata files for new structure instead of subdirectories
65
+ files = metadata_files
47
66
 
48
67
  if offset
49
68
  i = -1
50
69
  n = 0
51
- subdirectories do |subdirectory|
70
+ files.each do |metadata_file|
52
71
  i += 1
53
72
  next if i < offset
54
- node = node_for(subdirectory)
55
- block ? block.call(node) : accum.push(node)
73
+ node = Node.new(self, metadata_file)
74
+ block.call(node)
56
75
  n += 1
57
76
  break if limit && n >= limit
58
77
  end
59
78
  else
60
- subdirectories do |subdirectory|
61
- node = node_for(subdirectory)
62
- block ? block.call(node) : accum.push(node)
79
+ files.each do |metadata_file|
80
+ node = Node.new(self, metadata_file)
81
+ block.call(node)
63
82
  end
64
83
  end
65
84
 
66
- block ? self : accum
85
+ self
67
86
  end
68
87
 
69
88
  class Page < ::Array
@@ -148,8 +167,10 @@ module Ro
148
167
  block ? self : accum
149
168
  end
150
169
 
151
- def to_array(...)
152
- each(...)
170
+ def to_array(offset: nil, limit: nil)
171
+ accum = []
172
+ each(offset: offset, limit: limit) { |node| accum << node }
173
+ accum
153
174
  end
154
175
 
155
176
  alias to_a to_array
@@ -186,11 +207,28 @@ module Ro
186
207
  ]
187
208
  end
188
209
 
210
+ # T022: Modified to find nodes by metadata filename
189
211
  def get(name)
190
- paths_for(name).each do |path|
191
- next unless path.exist?
212
+ # Try to find metadata file for this node ID
213
+ extensions = %w[yml yaml json toml]
214
+ extensions.each do |ext|
215
+ metadata_file = @path.join("#{name}.#{ext}")
216
+ if metadata_file.exist? && metadata_file.file?
217
+ return Node.new(self, metadata_file)
218
+ end
219
+ end
192
220
 
193
- return node_for(path)
221
+ # Also try with slugified versions
222
+ [
223
+ Slug.for(name, :join => '-'),
224
+ Slug.for(name, :join => '_')
225
+ ].each do |slug|
226
+ extensions.each do |ext|
227
+ metadata_file = @path.join("#{slug}.#{ext}")
228
+ if metadata_file.exist? && metadata_file.file?
229
+ return Node.new(self, metadata_file)
230
+ end
231
+ end
194
232
  end
195
233
 
196
234
  nil
@@ -0,0 +1,285 @@
1
+ module Ro
2
+ class Migrator
3
+ attr_reader :root_path, :options
4
+
5
+ def initialize(root_path, options = {})
6
+ @root_path = Path.for(root_path)
7
+ @options = {
8
+ dry_run: false,
9
+ backup: false,
10
+ verbose: false,
11
+ force: false
12
+ }.merge(options)
13
+ end
14
+
15
+ def dry_run?
16
+ @options[:dry_run]
17
+ end
18
+
19
+ def backup?
20
+ @options[:backup]
21
+ end
22
+
23
+ def verbose?
24
+ @options[:verbose]
25
+ end
26
+
27
+ def force?
28
+ @options[:force]
29
+ end
30
+
31
+ # Validate the structure and return analysis
32
+ def validate
33
+ result = {
34
+ has_old_structure: false,
35
+ has_new_structure: false,
36
+ old_nodes: [],
37
+ new_nodes: [],
38
+ collections: []
39
+ }
40
+
41
+ root = Root.for(@root_path)
42
+
43
+ root.collections.each do |collection|
44
+ collection_name = collection.name
45
+ result[:collections] << collection_name
46
+
47
+ # Check for new structure (metadata files at collection level)
48
+ collection.metadata_files.each do |metadata_file|
49
+ node_id = metadata_file.basename.to_s.sub(/\.(yml|yaml|json|toml)$/, '')
50
+ result[:new_nodes] << {
51
+ collection: collection_name,
52
+ node_id: node_id,
53
+ metadata_file: metadata_file
54
+ }
55
+ result[:has_new_structure] = true
56
+ end
57
+
58
+ # Check for old structure (ALL subdirectories, with or without attributes)
59
+ # We need to migrate ALL nodes, even those without attributes.yml
60
+ collection.subdirectories.each do |subdir|
61
+ node_id = subdir.basename.to_s
62
+
63
+ # Skip if this node already has new-structure metadata
64
+ already_migrated = result[:new_nodes].any? { |n|
65
+ n[:collection] == collection_name && n[:node_id] == node_id
66
+ }
67
+ next if already_migrated
68
+
69
+ # Check if there's an attributes file (any format)
70
+ attributes_file = nil
71
+ has_attributes = false
72
+ %w[yml yaml json toml].each do |ext|
73
+ candidate = subdir.join("attributes.#{ext}")
74
+ if candidate.exist?
75
+ attributes_file = candidate
76
+ has_attributes = true
77
+ break
78
+ end
79
+ end
80
+
81
+ result[:old_nodes] << {
82
+ collection: collection_name,
83
+ node_id: node_id,
84
+ old_path: subdir,
85
+ attributes_file: attributes_file,
86
+ has_attributes: has_attributes
87
+ }
88
+ result[:has_old_structure] = true
89
+ end
90
+ end
91
+
92
+ result
93
+ end
94
+
95
+ # Preview migration without making changes
96
+ def preview
97
+ validation = validate
98
+ plan = []
99
+
100
+ validation[:old_nodes].each do |old_node|
101
+ collection_name = old_node[:collection]
102
+ node_id = old_node[:node_id]
103
+ old_path = old_node[:old_path]
104
+ has_attributes = old_node[:has_attributes]
105
+ attributes_file = old_node[:attributes_file]
106
+
107
+ collection_path = @root_path.join(collection_name)
108
+ new_metadata_file = collection_path.join("#{node_id}.yml")
109
+ new_asset_dir = collection_path.join(node_id)
110
+
111
+ actions = []
112
+ if has_attributes
113
+ actions << "Move #{attributes_file} → #{new_metadata_file}"
114
+ else
115
+ actions << "Create empty #{new_metadata_file} (node has no attributes)"
116
+ end
117
+ actions << "Assets remain in #{old_path}/assets/ (no change needed)"
118
+
119
+ plan << {
120
+ node_id: node_id,
121
+ collection: collection_name,
122
+ old_path: old_path,
123
+ new_metadata_file: new_metadata_file,
124
+ new_asset_dir: new_asset_dir,
125
+ has_attributes: has_attributes,
126
+ actions: actions
127
+ }
128
+ end
129
+
130
+ plan
131
+ end
132
+
133
+ # Migrate a single node
134
+ def migrate_node(collection_name, node_id)
135
+ log "Migrating #{collection_name}/#{node_id}..."
136
+
137
+ collection_path = @root_path.join(collection_name)
138
+ old_node_path = collection_path.join(node_id)
139
+
140
+ unless old_node_path.directory?
141
+ return { success: false, error: "Node directory not found: #{old_node_path}" }
142
+ end
143
+
144
+ new_metadata_file = collection_path.join("#{node_id}.yml")
145
+
146
+ # Look for attributes file in any format
147
+ old_attributes_file = nil
148
+ %w[yml yaml json toml].each do |ext|
149
+ candidate = old_node_path.join("attributes.#{ext}")
150
+ if candidate.exist?
151
+ old_attributes_file = candidate
152
+ break
153
+ end
154
+ end
155
+
156
+ unless dry_run?
157
+ if old_attributes_file
158
+ # Move existing attributes file to collection level
159
+ log " Moving #{old_attributes_file} → #{new_metadata_file}"
160
+ FileUtils.mv(old_attributes_file.to_s, new_metadata_file.to_s)
161
+ else
162
+ # Create empty metadata file for nodes without attributes
163
+ log " Creating empty metadata #{new_metadata_file} (node had no attributes)"
164
+ File.write(new_metadata_file.to_s, {}.to_yaml)
165
+ end
166
+ end
167
+
168
+ # Assets stay in assets/ subdirectory - no moving needed
169
+ # Old: collection/identifier/assets/foo.png
170
+ # New: collection/identifier/assets/foo.png (same location)
171
+
172
+ { success: true, node_id: node_id, had_attributes: !old_attributes_file.nil? }
173
+ end
174
+
175
+ # Migrate an entire collection
176
+ def migrate_collection(collection_name)
177
+ log "Migrating collection: #{collection_name}"
178
+
179
+ validation = validate
180
+ collection_nodes = validation[:old_nodes].select { |n| n[:collection] == collection_name }
181
+
182
+ migrated_count = 0
183
+ errors = []
184
+
185
+ collection_nodes.each do |old_node|
186
+ result = migrate_node(collection_name, old_node[:node_id])
187
+ if result[:success]
188
+ migrated_count += 1
189
+ else
190
+ errors << result[:error]
191
+ end
192
+ end
193
+
194
+ {
195
+ success: errors.empty?,
196
+ migrated_count: migrated_count,
197
+ errors: errors
198
+ }
199
+ end
200
+
201
+ # Migrate entire root
202
+ def migrate
203
+ log "Starting full migration of #{@root_path}"
204
+
205
+ if backup?
206
+ backup_path = backup
207
+ log "Created backup at #{backup_path}"
208
+ end
209
+
210
+ validation = validate
211
+
212
+ if validation[:old_nodes].empty?
213
+ log "No old structure nodes found to migrate"
214
+ return { success: true, nodes_migrated: 0, collections_migrated: 0 }
215
+ end
216
+
217
+ collections = validation[:old_nodes].map { |n| n[:collection] }.uniq
218
+ total_migrated = 0
219
+ collections_migrated = 0
220
+
221
+ collections.each do |collection_name|
222
+ result = migrate_collection(collection_name)
223
+ if result[:success]
224
+ collections_migrated += 1
225
+ total_migrated += result[:migrated_count]
226
+ end
227
+ end
228
+
229
+ log "Migration complete! Migrated #{total_migrated} nodes across #{collections_migrated} collections"
230
+
231
+ {
232
+ success: true,
233
+ nodes_migrated: total_migrated,
234
+ collections_migrated: collections_migrated
235
+ }
236
+ end
237
+
238
+ # Create backup
239
+ def backup
240
+ timestamp = Time.now.strftime('%Y%m%d%H%M%S')
241
+ backup_name = "#{@root_path.basename}.backup.#{timestamp}"
242
+ backup_path = @root_path.parent.join(backup_name)
243
+
244
+ log "Creating backup: #{backup_path}"
245
+
246
+ unless dry_run?
247
+ FileUtils.cp_r(@root_path.to_s, backup_path.to_s)
248
+ end
249
+
250
+ backup_path
251
+ end
252
+
253
+ # Rollback from backup
254
+ def rollback
255
+ # Find most recent backup
256
+ backup_pattern = "#{@root_path.basename}.backup.*"
257
+ backups = @root_path.parent.glob(backup_pattern).sort.reverse
258
+
259
+ if backups.empty?
260
+ return { success: false, error: "No backups found" }
261
+ end
262
+
263
+ backup_path = backups.first
264
+ log "Rolling back from #{backup_path}"
265
+
266
+ unless dry_run?
267
+ # Remove current root
268
+ FileUtils.rm_rf(@root_path.to_s)
269
+ # Restore from backup
270
+ FileUtils.cp_r(backup_path.to_s, @root_path.to_s)
271
+ end
272
+
273
+ {
274
+ success: true,
275
+ restored_from: backup_path
276
+ }
277
+ end
278
+
279
+ private
280
+
281
+ def log(message)
282
+ puts message if verbose? || dry_run?
283
+ end
284
+ end
285
+ end
data/lib/ro/node.rb CHANGED
@@ -2,16 +2,45 @@ module Ro
2
2
  class Node
3
3
  include Klass
4
4
 
5
- attr_reader :path, :root
5
+ attr_reader :path, :root, :metadata_file
6
+
7
+ # T023: Updated to accept (collection, metadata_file) for new structure
8
+ def initialize(collection_or_path, metadata_file = nil)
9
+ if metadata_file
10
+ # New structure: collection + metadata_file
11
+ @collection = collection_or_path
12
+ @metadata_file = Path.for(metadata_file)
13
+
14
+ # Raise error if metadata file doesn't exist
15
+ unless @metadata_file.exist?
16
+ raise Errno::ENOENT, "No such file or directory - #{@metadata_file}"
17
+ end
18
+
19
+ @root = @collection.root
20
+
21
+ # Derive node ID from metadata filename (without extension)
22
+ # T025: ID derived from metadata filename
23
+ node_id = @metadata_file.basename.to_s.sub(/\.(yml|yaml|json|toml)$/, '')
24
+
25
+ # Path is the node directory (sibling to metadata file)
26
+ @path = @collection.path.join(node_id)
27
+ else
28
+ # Old structure compatibility: just a path
29
+ @path = Path.for(collection_or_path)
30
+ @root = Root.for(@path.parent.parent)
31
+ @metadata_file = nil
32
+ end
6
33
 
7
- def initialize(path)
8
- @path = Path.for(path)
9
- @root = Root.for(@path.parent.parent)
10
34
  @attributes = :lazyload
11
35
  end
12
36
 
13
37
  def name
14
- @path.name
38
+ if @metadata_file
39
+ # T025: For new structure, name comes from metadata filename
40
+ @metadata_file.basename.to_s.sub(/\.(yml|yaml|json|toml)$/, '')
41
+ else
42
+ @path.name
43
+ end
15
44
  end
16
45
 
17
46
  def id
@@ -31,7 +60,7 @@ module Ro
31
60
  end
32
61
 
33
62
  def collection
34
- @root.collection_for(type)
63
+ @collection || @root.collection_for(type)
35
64
  end
36
65
 
37
66
  def attributes
@@ -54,12 +83,20 @@ module Ro
54
83
  @attributes
55
84
  end
56
85
 
86
+ # T026: Modified to load from external metadata_file (new structure)
57
87
  def _load_base_attributes
58
- glob = "attributes.{yml,yaml,json}"
88
+ if @metadata_file && @metadata_file.exist?
89
+ # New structure: load from explicit metadata file
90
+ attrs = _render(@metadata_file)
91
+ update_attributes!(attrs, file: @metadata_file)
92
+ else
93
+ # Old structure: search for attributes.yml in node directory
94
+ glob = "attributes.{yml,yaml,json}"
59
95
 
60
- @path.glob(glob) do |file|
61
- attrs = _render(file)
62
- update_attributes!(attrs, file:)
96
+ @path.glob(glob) do |file|
97
+ attrs = _render(file)
98
+ update_attributes!(attrs, file:)
99
+ end
63
100
  end
64
101
  end
65
102
 
@@ -150,7 +187,9 @@ module Ro
150
187
  @attributes.update(attrs)
151
188
  end
152
189
 
190
+ # T028: Updated ignore patterns for new structure
153
191
  def _ignored_files
192
+ # Both old and new structure: ignore attributes files and assets/ subdirectory
154
193
  ignored_files =
155
194
  %w[
156
195
  attributes.yml
@@ -159,9 +198,7 @@ module Ro
159
198
  ./assets/**/**
160
199
  ].map do |glob|
161
200
  @path.glob(glob).select(&:file?)
162
- end
163
-
164
- ignored_files.flatten
201
+ end.flatten
165
202
  end
166
203
 
167
204
  def _render(file)
@@ -206,7 +243,10 @@ module Ro
206
243
  path.relative_to(root)
207
244
  end
208
245
 
246
+ # T027: Updated to return assets/ subdirectory in both old and new structure
209
247
  def asset_dir
248
+ # Both old and new structure use assets/ subdirectory
249
+ # This prevents files from being rendered as templates
210
250
  path.join('assets')
211
251
  end
212
252
 
data/lib/ro/root.rb CHANGED
@@ -1,11 +1,18 @@
1
1
  module Ro
2
2
  class Root < Path
3
+ @@warned_paths = {}
4
+
3
5
  def identifier
4
6
  self
5
7
  end
6
8
 
9
+ def initialize(*)
10
+ super
11
+ check_for_unmigrated_structure!
12
+ end
13
+
7
14
  def collections(&block)
8
- accum = Collection::List.for(self)
15
+ accum = Collection::List.for(self)
9
16
 
10
17
  subdirectories do |subdirectory|
11
18
  collection = collection_for(subdirectory)
@@ -69,5 +76,72 @@ module Ro
69
76
  def method_missing(name, *args, **kws, &block)
70
77
  get(name) || super
71
78
  end
79
+
80
+ private
81
+
82
+ def check_for_unmigrated_structure!
83
+ # Use absolute path for deduplication
84
+ begin
85
+ path_key = File.expand_path(self.to_s)
86
+ rescue
87
+ path_key = self.to_s
88
+ end
89
+
90
+ # Skip if we've already warned for this path
91
+ return if @@warned_paths[path_key]
92
+
93
+ # Mark as checked (even if no warning needed)
94
+ @@warned_paths[path_key] = true
95
+
96
+ # Quick check: look for old structure (subdirs with attributes.yml)
97
+ # but no new structure (metadata files at collection level)
98
+ has_old = false
99
+ has_new = false
100
+
101
+ subdirectories.each do |subdir|
102
+ # Check for new structure (metadata files)
103
+ has_new = true if subdir.glob('*.{yml,yaml,json,toml}').any? { |f| f.file? }
104
+
105
+ # Check for old structure (nested attributes.yml)
106
+ subdir.subdirectories.each do |node_dir|
107
+ if (node_dir.join('attributes.yml').exist? ||
108
+ node_dir.join('attributes.yaml').exist? ||
109
+ node_dir.join('attributes.json').exist?)
110
+ has_old = true
111
+ break
112
+ end
113
+ end
114
+
115
+ break if has_old && has_new
116
+ end
117
+
118
+ if has_old && !has_new
119
+ warn_unmigrated_structure!
120
+ end
121
+ end
122
+
123
+ def warn_unmigrated_structure!
124
+ $stderr.puts ""
125
+ $stderr.puts "=" * 70
126
+ $stderr.puts "⚠️ WARNING: Old Ro asset structure detected!"
127
+ $stderr.puts "=" * 70
128
+ $stderr.puts ""
129
+ $stderr.puts "This Ro root contains assets in the OLD structure format:"
130
+ $stderr.puts " • identifier/attributes.yml"
131
+ $stderr.puts " • identifier/assets/"
132
+ $stderr.puts ""
133
+ $stderr.puts "Ro v5.0 uses a simplified NEW structure:"
134
+ $stderr.puts " • identifier.yml"
135
+ $stderr.puts " • identifier/"
136
+ $stderr.puts ""
137
+ $stderr.puts "Collections will NOT automatically discover old-structure nodes."
138
+ $stderr.puts ""
139
+ $stderr.puts "To migrate your data, run:"
140
+ $stderr.puts " #{$0.include?('bin/') ? './bin/ro' : 'ro'} migrate #{self}"
141
+ $stderr.puts ""
142
+ $stderr.puts "Or see MIGRATION.md for details."
143
+ $stderr.puts "=" * 70
144
+ $stderr.puts ""
145
+ end
72
146
  end
73
147
  end