ro 4.2.0 → 5.0.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 (252) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +57 -10
  3. data/LICENSE +1 -1
  4. data/MIGRATION.md +320 -0
  5. data/README.md +286 -111
  6. data/Rakefile +2 -2
  7. data/a.yml +60 -0
  8. data/bin/ro +10 -0
  9. data/lib/ro/_lib.rb +18 -6
  10. data/lib/ro/asset.rb +67 -16
  11. data/lib/ro/collection.rb +91 -10
  12. data/lib/ro/config.rb +4 -0
  13. data/lib/ro/error.rb +5 -2
  14. data/lib/ro/html.rb +23 -0
  15. data/lib/ro/html_safe.rb +143 -0
  16. data/lib/ro/methods.rb +95 -38
  17. data/lib/ro/migrator.rb +285 -0
  18. data/lib/ro/node.rb +128 -45
  19. data/lib/ro/path.rb +4 -0
  20. data/lib/ro/root.rb +75 -1
  21. data/lib/ro/script/migrate.rb +204 -0
  22. data/lib/ro/script/server.rb +1 -1
  23. data/lib/ro/template.rb +62 -22
  24. data/lib/ro/text.rb +120 -0
  25. data/lib/ro.rb +5 -0
  26. data/public/api/ro/index-1.json +997 -79
  27. data/public/api/ro/index.json +997 -79
  28. data/public/api/ro/nerd/fastest-possible-embeddings/index.json +90 -0
  29. data/public/api/ro/nerd/ima/index.json +49 -0
  30. data/public/api/ro/nerd/index/index.json +74 -0
  31. data/public/api/ro/nerd/index-1.json +204 -0
  32. data/public/api/ro/nerd/index.json +194 -0
  33. data/public/api/ro/pages/about/index.json +60 -0
  34. data/public/api/ro/pages/contact/index.json +50 -0
  35. data/public/api/ro/pages/cv/index.json +49 -0
  36. data/public/api/ro/pages/disco/index.json +117 -0
  37. data/public/api/ro/pages/index/index.json +30 -0
  38. data/public/api/ro/pages/index-1.json +366 -0
  39. data/public/api/ro/pages/index.json +356 -0
  40. data/public/api/ro/pages/jess/index.json +62 -0
  41. data/public/api/ro/pages/now/index.json +43 -0
  42. data/public/api/ro/posts/almost-died-in-an-ice-cave/index.json +265 -0
  43. data/public/api/ro/posts/facebook-and-global-extremism/index.json +90 -0
  44. data/public/api/ro/posts/index-1.json +461 -79
  45. data/public/api/ro/posts/index.json +461 -79
  46. data/public/api/ro/posts/lemmings-considered-harmful/index.json +49 -0
  47. data/public/api/ro/posts/lost-in-the-desert/index.json +49 -0
  48. data/public/api/ro/posts/mission/index.json +49 -0
  49. data/public/api/ro/posts/return-your-laptop/index.json +61 -0
  50. data/public/ro/nerd/fastest-possible-embeddings/assets/giraffe.jpeg +0 -0
  51. data/public/ro/nerd/fastest-possible-embeddings/assets/let-me-in.jpg +0 -0
  52. data/public/ro/nerd/fastest-possible-embeddings/assets/src/fastembed.js +70 -0
  53. data/public/ro/nerd/fastest-possible-embeddings/assets/src/fastembed.rs +68 -0
  54. data/public/ro/nerd/fastest-possible-embeddings/assets/terminal.jpg +0 -0
  55. data/public/ro/nerd/fastest-possible-embeddings/body.md +266 -0
  56. data/public/ro/nerd/fastest-possible-embeddings.yml +7 -0
  57. data/public/ro/nerd/ima/assets/og.jpeg +0 -0
  58. data/public/ro/nerd/ima/body.md +22 -0
  59. data/public/ro/nerd/ima.yml +8 -0
  60. data/public/ro/nerd/index/assets/giraffe.jpeg +0 -0
  61. data/public/ro/nerd/index/assets/let-me-in.jpg +0 -0
  62. data/public/ro/nerd/index/assets/terminal.jpg +0 -0
  63. data/public/ro/nerd/index/body.md +130 -0
  64. data/public/ro/nerd/index.yml +7 -0
  65. data/public/ro/pages/about/assets/og.jpeg +0 -0
  66. data/public/ro/pages/about/assets/speak-english-pulp-fiction.gif +0 -0
  67. data/public/ro/pages/about/body.md +40 -0
  68. data/public/ro/pages/contact/assets/giraffe.jpeg +0 -0
  69. data/public/ro/pages/contact/body.md +9 -0
  70. data/public/ro/pages/contact.yml +7 -0
  71. data/public/ro/pages/cv/assets/ara.jpg +0 -0
  72. data/public/ro/pages/cv/body.md +122 -0
  73. data/public/ro/pages/cv.yml +6 -0
  74. data/public/ro/pages/disco/assets/disco.jpg +0 -0
  75. data/public/ro/pages/disco/assets/disco.png +0 -0
  76. data/public/ro/pages/disco/assets/speak-english-pulp-fiction.gif +0 -0
  77. data/public/ro/pages/disco/assets/src/environment.md +2354 -0
  78. data/public/ro/pages/disco/assets/src/fortune-500.md +2518 -0
  79. data/public/ro/pages/disco/assets/src/greed.md +2703 -0
  80. data/public/ro/pages/disco/assets/src/up-at-night.md +2337 -0
  81. data/public/ro/pages/disco/body.md +99 -0
  82. data/public/ro/pages/disco/samples/environment.md +2354 -0
  83. data/public/ro/pages/disco/samples/fortune-500.md +2518 -0
  84. data/public/ro/pages/disco/samples/greed.md +2703 -0
  85. data/public/ro/pages/disco/samples/up-at-night.md +2337 -0
  86. data/public/ro/pages/disco.yml +9 -0
  87. data/public/ro/pages/index/body.md +15 -0
  88. data/public/ro/pages/index.yml +1 -0
  89. data/public/ro/pages/jess/assets/og.jpg +0 -0
  90. data/public/ro/pages/jess/assets/speak-english-pulp-fiction.gif +0 -0
  91. data/public/ro/pages/jess/body.md +3 -0
  92. data/public/ro/pages/jess.yml +7 -0
  93. data/public/ro/pages/now/assets/speak-english-pulp-fiction.gif +0 -0
  94. data/public/ro/pages/now/body.md +24 -0
  95. data/public/ro/pages/now.yml +1 -0
  96. data/public/ro/posts/almost-died-in-an-ice-cave/assets/image1.png +0 -0
  97. data/public/ro/posts/almost-died-in-an-ice-cave/assets/image10.png +0 -0
  98. data/public/ro/posts/almost-died-in-an-ice-cave/assets/image11.png +0 -0
  99. data/public/ro/posts/almost-died-in-an-ice-cave/assets/image12.png +0 -0
  100. data/public/ro/posts/almost-died-in-an-ice-cave/assets/image13.png +0 -0
  101. data/public/ro/posts/almost-died-in-an-ice-cave/assets/image14.png +0 -0
  102. data/public/ro/posts/almost-died-in-an-ice-cave/assets/image15.png +0 -0
  103. data/public/ro/posts/almost-died-in-an-ice-cave/assets/image2.png +0 -0
  104. data/public/ro/posts/almost-died-in-an-ice-cave/assets/image3.png +0 -0
  105. data/public/ro/posts/almost-died-in-an-ice-cave/assets/image4.png +0 -0
  106. data/public/ro/posts/almost-died-in-an-ice-cave/assets/image5.png +0 -0
  107. data/public/ro/posts/almost-died-in-an-ice-cave/assets/image6.png +0 -0
  108. data/public/ro/posts/almost-died-in-an-ice-cave/assets/image7.png +0 -0
  109. data/public/ro/posts/almost-died-in-an-ice-cave/assets/image8.png +0 -0
  110. data/public/ro/posts/almost-died-in-an-ice-cave/assets/image9.png +0 -0
  111. data/public/ro/posts/almost-died-in-an-ice-cave/assets/josh-pointing.jpg +0 -0
  112. data/public/ro/posts/almost-died-in-an-ice-cave/assets/levi-rawr.png +0 -0
  113. data/public/ro/posts/almost-died-in-an-ice-cave/assets/og.jpg +0 -0
  114. data/public/ro/posts/almost-died-in-an-ice-cave/assets/purple-heart.jpg +0 -0
  115. data/public/ro/posts/almost-died-in-an-ice-cave/body.md +419 -0
  116. data/public/ro/posts/almost-died-in-an-ice-cave.yml +6 -0
  117. data/public/ro/posts/facebook-and-global-extremism/assets/background.html +125 -0
  118. data/public/ro/posts/facebook-and-global-extremism/assets/background.md +95 -0
  119. data/public/ro/posts/facebook-and-global-extremism/assets/og.jpg +0 -0
  120. data/public/ro/posts/facebook-and-global-extremism/assets/prompt.txt +122 -0
  121. data/public/ro/posts/facebook-and-global-extremism/assets/results.md +183 -0
  122. data/public/ro/posts/facebook-and-global-extremism/assets/survey.txt +190 -0
  123. data/public/ro/posts/facebook-and-global-extremism/body.md +393 -0
  124. data/public/ro/posts/facebook-and-global-extremism.yml +7 -0
  125. data/public/ro/posts/lemmings-considered-harmful/assets/lemming.jpeg +0 -0
  126. data/public/ro/posts/lemmings-considered-harmful/body.md +43 -0
  127. data/public/ro/posts/lemmings-considered-harmful.yml +6 -0
  128. data/public/ro/posts/lost-in-the-desert/assets/og.jpg +0 -0
  129. data/public/ro/posts/lost-in-the-desert/body.md +7 -0
  130. data/public/ro/posts/lost-in-the-desert.yml +6 -0
  131. data/public/ro/posts/mission/assets/og.jpg +0 -0
  132. data/public/ro/posts/mission/body.md +4 -0
  133. data/public/ro/posts/mission.yml +6 -0
  134. data/public/ro/posts/return-your-laptop/assets/og.jpg +0 -0
  135. data/public/ro/posts/return-your-laptop/assets/return-your-laptop.png +0 -0
  136. data/public/ro/posts/return-your-laptop/body.md +58 -0
  137. data/public/ro/posts/return-your-laptop.yml +6 -0
  138. data/ro.gemspec +369 -49
  139. data/scripts/speedtest.rb +324 -0
  140. data/specs/001-simplify-asset-structure/IMPLEMENTATION_SUMMARY.md +212 -0
  141. data/specs/001-simplify-asset-structure/checklists/requirements.md +36 -0
  142. data/specs/001-simplify-asset-structure/contracts/collection_api.md +407 -0
  143. data/specs/001-simplify-asset-structure/contracts/migrator_api.md +461 -0
  144. data/specs/001-simplify-asset-structure/contracts/node_api.md +294 -0
  145. data/specs/001-simplify-asset-structure/data-model.md +381 -0
  146. data/specs/001-simplify-asset-structure/plan.md +90 -0
  147. data/specs/001-simplify-asset-structure/quickstart.md +575 -0
  148. data/specs/001-simplify-asset-structure/research.md +333 -0
  149. data/specs/001-simplify-asset-structure/spec.md +127 -0
  150. data/specs/001-simplify-asset-structure/tasks.md +349 -0
  151. data/test/fixtures/new_structure/mixed/test-json.json +5 -0
  152. data/test/fixtures/new_structure/mixed/test-yaml.yml +3 -0
  153. data/test/fixtures/new_structure/posts/metadata-only.yml +7 -0
  154. data/test/fixtures/new_structure/posts/nested-test/assets/subdirectory/image.png +2 -0
  155. data/test/fixtures/new_structure/posts/nested-test.yml +7 -0
  156. data/test/fixtures/new_structure/posts/sample-post/assets/body.md +5 -0
  157. data/test/fixtures/new_structure/posts/sample-post/assets/image.jpg +2 -0
  158. data/test/fixtures/new_structure/posts/sample-post.yml +7 -0
  159. data/test/fixtures/old_structure/posts/assets-only/assets/test.txt +1 -0
  160. data/test/fixtures/old_structure/posts/sample-post/assets/body.md +5 -0
  161. data/test/fixtures/old_structure/posts/sample-post/assets/image.jpg +2 -0
  162. data/test/fixtures/old_structure/posts/sample-post/attributes.yml +2 -0
  163. data/test/integration/ro_integration_test.rb +165 -0
  164. data/test/test_helper.rb +149 -0
  165. data/test/tmp/migration_test_1760746513.backup.20251018001513/migration_test_1760746513/posts/sample-post/assets/image.jpg +2 -0
  166. data/test/tmp/migration_test_1760746513.backup.20251018001513/migration_test_1760746513/posts/sample-post/attributes.yml +7 -0
  167. data/test/tmp/migration_test_1760746513.backup.20251018001513/migration_test_1760746513/posts/sample-post/body.md +5 -0
  168. data/test/tmp/migration_test_1760746513.backup.20251018001513/posts/sample-post/assets/image.jpg +2 -0
  169. data/test/tmp/migration_test_1760746513.backup.20251018001513/posts/sample-post/attributes.yml +7 -0
  170. data/test/tmp/migration_test_1760746513.backup.20251018001513/posts/sample-post/body.md +5 -0
  171. data/test/tmp/migration_test_1760746556.backup.20251018001556/migration_test_1760746556/posts/sample-post/assets/image.jpg +2 -0
  172. data/test/tmp/migration_test_1760746556.backup.20251018001556/migration_test_1760746556/posts/sample-post/attributes.yml +7 -0
  173. data/test/tmp/migration_test_1760746556.backup.20251018001556/migration_test_1760746556/posts/sample-post/body.md +5 -0
  174. data/test/tmp/migration_test_1760746556.backup.20251018001556/posts/sample-post/assets/image.jpg +2 -0
  175. data/test/tmp/migration_test_1760746556.backup.20251018001556/posts/sample-post/attributes.yml +7 -0
  176. data/test/tmp/migration_test_1760746556.backup.20251018001556/posts/sample-post/body.md +5 -0
  177. data/test/tmp/migration_test_1760755248.backup.20251018024048/migration_test_1760755248/posts/sample-post/assets/image.jpg +2 -0
  178. data/test/tmp/migration_test_1760755248.backup.20251018024048/migration_test_1760755248/posts/sample-post/attributes.yml +7 -0
  179. data/test/tmp/migration_test_1760755248.backup.20251018024048/migration_test_1760755248/posts/sample-post/body.md +5 -0
  180. data/test/tmp/migration_test_1760755248.backup.20251018024048/posts/sample-post/assets/image.jpg +2 -0
  181. data/test/tmp/migration_test_1760755248.backup.20251018024048/posts/sample-post/attributes.yml +7 -0
  182. data/test/tmp/migration_test_1760755248.backup.20251018024048/posts/sample-post/body.md +5 -0
  183. data/test/tmp/migration_test_1760758803.backup.20251018034003/migration_test_1760758803/posts/sample-post/body.md +5 -0
  184. data/test/tmp/migration_test_1760758803.backup.20251018034003/migration_test_1760758803/posts/sample-post/image.jpg +2 -0
  185. data/test/tmp/migration_test_1760758803.backup.20251018034003/migration_test_1760758803/posts/sample-post.yml +7 -0
  186. data/test/tmp/migration_test_1760758803.backup.20251018034003/posts/sample-post/body.md +5 -0
  187. data/test/tmp/migration_test_1760758803.backup.20251018034003/posts/sample-post/image.jpg +2 -0
  188. data/test/tmp/migration_test_1760758803.backup.20251018034003/posts/sample-post.yml +7 -0
  189. data/test/tmp/migration_test_1760758869.backup.20251018034109/migration_test_1760758869/posts/sample-post/assets/body.md +5 -0
  190. data/test/tmp/migration_test_1760758869.backup.20251018034109/migration_test_1760758869/posts/sample-post/assets/image.jpg +2 -0
  191. data/test/tmp/migration_test_1760758869.backup.20251018034109/migration_test_1760758869/posts/sample-post/attributes.yml +2 -0
  192. data/test/tmp/migration_test_1760758869.backup.20251018034109/posts/sample-post/assets/body.md +5 -0
  193. data/test/tmp/migration_test_1760758869.backup.20251018034109/posts/sample-post/assets/image.jpg +2 -0
  194. data/test/tmp/migration_test_1760758869.backup.20251018034109/posts/sample-post/attributes.yml +2 -0
  195. data/test/tmp/migration_test_1760758920.backup.20251018034200/migration_test_1760758920/posts/sample-post/assets/body.md +5 -0
  196. data/test/tmp/migration_test_1760758920.backup.20251018034200/migration_test_1760758920/posts/sample-post/assets/image.jpg +2 -0
  197. data/test/tmp/migration_test_1760758920.backup.20251018034200/migration_test_1760758920/posts/sample-post/attributes.yml +2 -0
  198. data/test/tmp/migration_test_1760758920.backup.20251018034200/posts/sample-post/assets/body.md +5 -0
  199. data/test/tmp/migration_test_1760758920.backup.20251018034200/posts/sample-post/assets/image.jpg +2 -0
  200. data/test/tmp/migration_test_1760758920.backup.20251018034200/posts/sample-post/attributes.yml +2 -0
  201. data/test/tmp/migration_test_1760824728.backup.20251018215848/migration_test_1760824728/posts/assets-only/assets/test.txt +1 -0
  202. data/test/tmp/migration_test_1760824728.backup.20251018215848/migration_test_1760824728/posts/sample-post/assets/body.md +5 -0
  203. data/test/tmp/migration_test_1760824728.backup.20251018215848/migration_test_1760824728/posts/sample-post/assets/image.jpg +2 -0
  204. data/test/tmp/migration_test_1760824728.backup.20251018215848/migration_test_1760824728/posts/sample-post/attributes.yml +2 -0
  205. data/test/tmp/migration_test_1760824728.backup.20251018215848/posts/assets-only/assets/test.txt +1 -0
  206. data/test/tmp/migration_test_1760824728.backup.20251018215848/posts/sample-post/assets/body.md +5 -0
  207. data/test/tmp/migration_test_1760824728.backup.20251018215848/posts/sample-post/assets/image.jpg +2 -0
  208. data/test/tmp/migration_test_1760824728.backup.20251018215848/posts/sample-post/attributes.yml +2 -0
  209. data/test/tmp/migration_test_1760844153.backup.20251019032233/migration_test_1760844153/posts/assets-only/assets/test.txt +1 -0
  210. data/test/tmp/migration_test_1760844153.backup.20251019032233/migration_test_1760844153/posts/sample-post/assets/body.md +5 -0
  211. data/test/tmp/migration_test_1760844153.backup.20251019032233/migration_test_1760844153/posts/sample-post/assets/image.jpg +2 -0
  212. data/test/tmp/migration_test_1760844153.backup.20251019032233/migration_test_1760844153/posts/sample-post/attributes.yml +2 -0
  213. data/test/tmp/migration_test_1760844153.backup.20251019032233/posts/assets-only/assets/test.txt +1 -0
  214. data/test/tmp/migration_test_1760844153.backup.20251019032233/posts/sample-post/assets/body.md +5 -0
  215. data/test/tmp/migration_test_1760844153.backup.20251019032233/posts/sample-post/assets/image.jpg +2 -0
  216. data/test/tmp/migration_test_1760844153.backup.20251019032233/posts/sample-post/attributes.yml +2 -0
  217. data/test/tmp/new_structure_test_1760746452/mixed/test-json.json +5 -0
  218. data/test/tmp/new_structure_test_1760746452/mixed/test-yaml.yml +3 -0
  219. data/test/tmp/new_structure_test_1760746452/posts/metadata-only.yml +7 -0
  220. data/test/tmp/new_structure_test_1760746452/posts/nested-test/subdirectory/image.png +2 -0
  221. data/test/tmp/new_structure_test_1760746452/posts/nested-test.yml +7 -0
  222. data/test/tmp/new_structure_test_1760746452/posts/sample-post/body.md +5 -0
  223. data/test/tmp/new_structure_test_1760746452/posts/sample-post/image.jpg +2 -0
  224. data/test/tmp/new_structure_test_1760746452/posts/sample-post.yml +7 -0
  225. data/test/unit/asset_test.rb +90 -0
  226. data/test/unit/collection_test.rb +127 -0
  227. data/test/unit/migrator_test.rb +209 -0
  228. data/test/unit/node_test.rb +138 -0
  229. data/tmp/gem-details.oe +0 -0
  230. metadata +250 -33
  231. data/public/api/ro/posts/first_post/index.json +0 -52
  232. data/public/api/ro/posts/second_post/index.json +0 -51
  233. data/public/api/ro/posts/third_post/index.json +0 -51
  234. data/public/ro/posts/first_post/assets/foo/bar/baz.jpg +0 -0
  235. data/public/ro/posts/first_post/assets/foo.jpg +0 -0
  236. data/public/ro/posts/first_post/assets/src/foo/bar.rb +0 -3
  237. data/public/ro/posts/first_post/attributes.yml +0 -2
  238. data/public/ro/posts/first_post/blurb.erb.md +0 -7
  239. data/public/ro/posts/first_post/body.md +0 -16
  240. data/public/ro/posts/first_post/testing.txt +0 -3
  241. data/public/ro/posts/second_post/assets/foo/bar/baz.jpg +0 -0
  242. data/public/ro/posts/second_post/assets/foo.jpg +0 -0
  243. data/public/ro/posts/second_post/assets/src/foo/bar.rb +0 -3
  244. data/public/ro/posts/second_post/attributes.yml +0 -2
  245. data/public/ro/posts/second_post/blurb.erb.md +0 -5
  246. data/public/ro/posts/second_post/body.md +0 -16
  247. data/public/ro/posts/third_post/assets/foo/bar/baz.jpg +0 -0
  248. data/public/ro/posts/third_post/assets/foo.jpg +0 -0
  249. data/public/ro/posts/third_post/assets/src/foo/bar.rb +0 -3
  250. data/public/ro/posts/third_post/attributes.yml +0 -2
  251. data/public/ro/posts/third_post/blurb.erb.md +0 -5
  252. data/public/ro/posts/third_post/body.md +0 -16
@@ -0,0 +1,407 @@
1
+ # Contract: Collection API
2
+
3
+ **Version**: 5.0.0 (new structure)
4
+ **Date**: 2025-10-17
5
+
6
+ ## Overview
7
+
8
+ Defines the programmatic interface for the `Ro::Collection` class in the new simplified asset structure. Collections discover and manage Nodes using the new metadata file-based pattern.
9
+
10
+ ## Constructor
11
+
12
+ ### `Collection.new(root, name)`
13
+
14
+ **Purpose**: Initialize a collection from a root and collection name.
15
+
16
+ **Parameters**:
17
+ - `root` (Ro::Root): Parent root object
18
+ - `name` (String): Collection name (matches directory name)
19
+
20
+ **Returns**: `Ro::Collection` instance
21
+
22
+ **Example**:
23
+ ```ruby
24
+ root = Ro::Root.new('/path/to/ro')
25
+ collection = Ro::Collection.new(root, 'posts')
26
+ ```
27
+
28
+ **Unchanged**: Constructor signature remains the same in both v4.x and v5.0.
29
+
30
+ ---
31
+
32
+ ## Instance Methods
33
+
34
+ ### `#name` → String
35
+
36
+ **Purpose**: Returns the collection name.
37
+
38
+ **Returns**: String (e.g., "posts")
39
+
40
+ **Example**:
41
+ ```ruby
42
+ collection.name # => "posts"
43
+ ```
44
+
45
+ **Unchanged**: Same in both structures.
46
+
47
+ ---
48
+
49
+ ### `#path` → Pathname
50
+
51
+ **Purpose**: Returns the path to the collection directory.
52
+
53
+ **Returns**: Pathname
54
+
55
+ **Example**:
56
+ ```ruby
57
+ collection.path # => #<Pathname:/path/to/ro/posts>
58
+ ```
59
+
60
+ **Unchanged**: Same in both structures.
61
+
62
+ ---
63
+
64
+ ### `#nodes` → Array<Ro::Node>
65
+
66
+ **Purpose**: Returns all nodes in the collection.
67
+
68
+ **Returns**: Array of `Ro::Node` instances
69
+
70
+ **Example**:
71
+ ```ruby
72
+ collection.nodes # => [#<Ro::Node id="post-1">, #<Ro::Node id="post-2">]
73
+ ```
74
+
75
+ **Behavior Change**:
76
+ - Old structure: Discovers nodes by iterating subdirectories
77
+ - New structure: Discovers nodes by finding metadata files (`.yml`, `.yaml`, `.json`, `.toml`)
78
+
79
+ **Unchanged**: Return type and usage remain the same.
80
+
81
+ ---
82
+
83
+ ### `#each(&block)` → Enumerator
84
+
85
+ **Purpose**: Iterates over each node in the collection.
86
+
87
+ **Parameters**:
88
+ - `block` (optional): Block to execute for each node
89
+
90
+ **Returns**: Enumerator if no block given
91
+
92
+ **Example**:
93
+ ```ruby
94
+ collection.each do |node|
95
+ puts node.id
96
+ end
97
+
98
+ # Or without block:
99
+ collection.each.map(&:id) # => ["post-1", "post-2"]
100
+ ```
101
+
102
+ **Behavior Change**:
103
+ - Old structure: Iterates subdirectories, creates Node from each
104
+ - New structure: Iterates metadata files, creates Node from each
105
+
106
+ **Unchanged**: API remains the same, only discovery mechanism changes.
107
+
108
+ ---
109
+
110
+ ### `#node_for(identifier)` → Ro::Node | nil
111
+
112
+ **Purpose**: Returns a specific node by identifier.
113
+
114
+ **Parameters**:
115
+ - `identifier` (String): Node ID
116
+
117
+ **Returns**: `Ro::Node` instance, or `nil` if not found
118
+
119
+ **Example**:
120
+ ```ruby
121
+ node = collection.node_for('my-post') # => #<Ro::Node id="my-post">
122
+ missing = collection.node_for('nonexistent') # => nil
123
+ ```
124
+
125
+ **Behavior Change**:
126
+ - Old structure: Looks for subdirectory named `identifier`
127
+ - New structure: Looks for metadata file named `identifier.{yml,yaml,json,toml}`
128
+
129
+ **Unchanged**: API remains the same.
130
+
131
+ ---
132
+
133
+ ### `#[]` (alias: `#get`) → Ro::Node | nil
134
+
135
+ **Purpose**: Access a specific node by identifier (alias for `#node_for`).
136
+
137
+ **Parameters**:
138
+ - `identifier` (String): Node ID
139
+
140
+ **Returns**: `Ro::Node` instance, or `nil` if not found
141
+
142
+ **Example**:
143
+ ```ruby
144
+ node = collection['my-post'] # => #<Ro::Node id="my-post">
145
+ ```
146
+
147
+ **Unchanged**: Same in both structures.
148
+
149
+ ---
150
+
151
+ ### `#size` (alias: `#count`, `#length`) → Integer
152
+
153
+ **Purpose**: Returns the number of nodes in the collection.
154
+
155
+ **Returns**: Integer
156
+
157
+ **Example**:
158
+ ```ruby
159
+ collection.size # => 42
160
+ ```
161
+
162
+ **Unchanged**: Same in both structures (just counts discovered nodes).
163
+
164
+ ---
165
+
166
+ ## Discovery Logic (Internal)
167
+
168
+ ### OLD Structure Discovery (v4.x):
169
+
170
+ ```ruby
171
+ def each(&block)
172
+ subdirectories.each do |subdir|
173
+ node = Ro::Node.new(self, subdir)
174
+ block.call(node)
175
+ end
176
+ end
177
+
178
+ def subdirectories
179
+ path.children.select(&:directory?).sort
180
+ end
181
+ ```
182
+
183
+ **Pattern**: Iterate directories → each directory is a node → node loads `attributes.yml` internally
184
+
185
+ ---
186
+
187
+ ### NEW Structure Discovery (v5.0):
188
+
189
+ ```ruby
190
+ def each(&block)
191
+ metadata_files.each do |metadata_file|
192
+ node = Ro::Node.new(self, metadata_file)
193
+ block.call(node)
194
+ end
195
+ end
196
+
197
+ def metadata_files
198
+ extensions = %w[yml yaml json toml]
199
+ extensions.flat_map do |ext|
200
+ path.glob("*.#{ext}").select(&:file?)
201
+ end.sort
202
+ end
203
+ ```
204
+
205
+ **Pattern**: Scan for metadata files → each file is a node → node derives ID from filename
206
+
207
+ ---
208
+
209
+ ## Test Requirements
210
+
211
+ ### Unit Tests
212
+
213
+ Must verify for the NEW structure:
214
+
215
+ 1. **Initialization**:
216
+ - ✓ Creates collection from root and name
217
+ - ✓ Sets correct path (`root.path / name`)
218
+
219
+ 2. **Node Discovery**:
220
+ - ✓ Finds nodes by detecting metadata files
221
+ - ✓ Supports multiple metadata formats (`.yml`, `.yaml`, `.json`, `.toml`)
222
+ - ✓ Ignores non-metadata files
223
+ - ✓ Returns nodes in sorted order (by filename)
224
+ - ✓ Handles empty collection (no metadata files)
225
+
226
+ 3. **Node Access**:
227
+ - ✓ `#node_for` returns correct node by ID
228
+ - ✓ `#node_for` returns `nil` for missing nodes
229
+ - ✓ `#[]` works as alias for `#node_for`
230
+
231
+ 4. **Enumeration**:
232
+ - ✓ `#each` iterates over all nodes
233
+ - ✓ `#each` returns Enumerator when no block given
234
+ - ✓ `#nodes` returns array of all nodes
235
+ - ✓ `#size` returns correct count
236
+
237
+ ### Integration Tests
238
+
239
+ Must verify interaction with Root and Node:
240
+
241
+ 1. **Collection Discovery**:
242
+ - ✓ Root discovers collections as subdirectories
243
+ - ✓ Collections discover nodes as metadata files within those subdirectories
244
+
245
+ 2. **Node Creation**:
246
+ - ✓ Collection passes correct metadata file path to Node constructor
247
+ - ✓ Created nodes have correct collection reference
248
+ - ✓ Created nodes have IDs matching metadata filenames (without extension)
249
+
250
+ 3. **Mixed Formats**:
251
+ - ✓ Collection with both `.yml` and `.json` nodes works correctly
252
+ - ✓ Nodes with same ID but different extensions are detected as conflicts
253
+
254
+ ---
255
+
256
+ ## Edge Cases
257
+
258
+ ### Multiple Metadata Files for Same ID
259
+
260
+ **Scenario**: Both `my-post.yml` and `my-post.json` exist
261
+
262
+ **Expected Behavior**:
263
+ - **Strict mode**: Raise error (ambiguous node)
264
+ - **Lenient mode**: Use first found (alphabetically: `.json` < `.yml`)
265
+
266
+ **Recommendation**: Raise error to prevent confusion. Users should have only one metadata file per node.
267
+
268
+ **Test**:
269
+ ```ruby
270
+ # Given:
271
+ # posts/my-post.yml
272
+ # posts/my-post.json
273
+
274
+ expect { collection.node_for('my-post') }.to raise_error(Ro::AmbiguousNodeError)
275
+ ```
276
+
277
+ ---
278
+
279
+ ### Metadata File with No Corresponding Directory
280
+
281
+ **Scenario**: `my-post.yml` exists but no `my-post/` directory
282
+
283
+ **Expected Behavior**: Valid (metadata-only node per FR-007)
284
+
285
+ **Test**:
286
+ ```ruby
287
+ # Given:
288
+ # posts/my-post.yml
289
+ # (no posts/my-post/ directory)
290
+
291
+ node = collection.node_for('my-post')
292
+ expect(node).to be_present
293
+ expect(node.asset_paths).to be_empty
294
+ ```
295
+
296
+ ---
297
+
298
+ ### Directory with No Corresponding Metadata File
299
+
300
+ **Scenario**: `my-post/` directory exists but no `my-post.yml`
301
+
302
+ **Expected Behavior**: Not discovered as a node (metadata file is the authority)
303
+
304
+ **Test**:
305
+ ```ruby
306
+ # Given:
307
+ # posts/my-post/
308
+ # (no posts/my-post.yml)
309
+
310
+ node = collection.node_for('my-post')
311
+ expect(node).to be_nil
312
+ ```
313
+
314
+ **Rationale**: Metadata file presence is the canonical marker for a node. Orphaned directories should be ignored or flagged as warnings.
315
+
316
+ ---
317
+
318
+ ### Both Old and New Structure Exist
319
+
320
+ **Scenario**: Both `my-post/attributes.yml` (old) and `my-post.yml` (new) exist
321
+
322
+ **Expected Behavior** (per FR-011): Prefer old structure until migration
323
+
324
+ **Test**:
325
+ ```ruby
326
+ # Given:
327
+ # posts/my-post/attributes.yml (old structure)
328
+ # posts/my-post.yml (new structure)
329
+
330
+ # In v5.0 (post-migration), only new structure should be detected:
331
+ node = collection.node_for('my-post')
332
+ expect(node.metadata_file.to_s).to end_with('my-post.yml') # NEW structure
333
+ ```
334
+
335
+ **NOTE**: This scenario should only occur during migration. The migration tool should prevent this by removing old structure after verifying new structure.
336
+
337
+ ---
338
+
339
+ ## Breaking Changes from v4.x
340
+
341
+ | Method | v4.x Behavior | v5.0 Behavior | Breaking? |
342
+ |--------|---------------|---------------|-----------|
343
+ | `#each` | Iterates subdirectories | Iterates metadata files | NO (internal change) |
344
+ | `#nodes` | Nodes from subdirectories | Nodes from metadata files | NO (same interface) |
345
+ | `#node_for` | Looks for `id/` directory | Looks for `id.{yml,json,...}` file | NO (same interface) |
346
+
347
+ **Migration Impact**: External API remains unchanged. The only breaking change is in how nodes are discovered internally, which is transparent to library users. However, users must migrate their data from old to new structure before upgrading to v5.0.
348
+
349
+ ---
350
+
351
+ ## Performance Considerations
352
+
353
+ ### Discovery Performance
354
+
355
+ **Old structure**:
356
+ ```ruby
357
+ path.children.select(&:directory?) # O(N) where N = files + directories
358
+ ```
359
+
360
+ **New structure**:
361
+ ```ruby
362
+ path.glob("*.yml") + path.glob("*.json") + ... # O(M) where M = total files
363
+ ```
364
+
365
+ **Analysis**:
366
+ - Old: Must stat every entry to check if directory
367
+ - New: Must glob for each extension (typically 2-4 globs)
368
+ - **Result**: Similar performance, potentially faster for new structure (globs are optimized)
369
+
370
+ **Benchmark target**: <100ms for collections with 10,000 nodes (per SC-001)
371
+
372
+ ---
373
+
374
+ ## Migration Compatibility
375
+
376
+ ### Transition Strategy
377
+
378
+ During migration from v4.x to v5.0, the Collection class should:
379
+
380
+ 1. **Detect structure type**: Check if nodes exist as metadata files (new) or subdirectories (old)
381
+ 2. **Raise error for mixed structures**: If some nodes are old and some are new, raise error directing user to run migration tool
382
+ 3. **Log deprecation warnings**: In v4.x, log warning if old structure detected
383
+
384
+ **Implementation suggestion**:
385
+ ```ruby
386
+ def each(&block)
387
+ if new_structure?
388
+ # Use metadata file discovery
389
+ metadata_files.each { |f| yield Ro::Node.new(self, f) }
390
+ elsif old_structure?
391
+ # Use directory discovery (v4.x compatibility)
392
+ subdirectories.each { |d| yield Ro::Node.new(self, d) }
393
+ else
394
+ raise "Mixed structures detected. Run migration tool first."
395
+ end
396
+ end
397
+
398
+ def new_structure?
399
+ metadata_files.any?
400
+ end
401
+
402
+ def old_structure?
403
+ subdirectories.any? { |d| (d / 'attributes.yml').exist? }
404
+ end
405
+ ```
406
+
407
+ **NOTE**: This compatibility code is ONLY for migration period. In final v5.0 release, only new structure should be supported.