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,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
@@ -47,40 +76,42 @@ module Ro
47
76
  @attributes = Map.new
48
77
 
49
78
  _load_base_attributes
79
+ _load_file_attributes
50
80
  _load_asset_attributes
51
81
  _load_meta_attributes
52
- _load_file_attributes
53
82
 
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
- disallowed =
59
- %w[
60
- assets
61
- _meta
62
- ]
63
-
64
- glob =
65
- "attributes.{yml,yaml,json}"
66
-
67
- @path.glob(glob) do |file|
68
- attrs = _render(file)
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}"
69
95
 
70
- disallowed.each do |key|
71
- Ro.error!("#{ file } must not contain the key #{key.inspect}") if attrs.has_key?(key)
96
+ @path.glob(glob) do |file|
97
+ attrs = _render(file)
98
+ update_attributes!(attrs, file:)
72
99
  end
73
-
74
- @attributes.update(attrs)
75
100
  end
76
101
  end
77
102
 
78
-
79
103
  def _load_asset_attributes
80
104
  {}.tap do |hash|
81
105
  assets.each do |asset|
82
106
  key = asset.name
83
- value = { url: asset.url, path: asset.path.relative_to(@root), src: asset.src }
107
+ url = asset.url
108
+ path = asset.path.relative_to(@root)
109
+ src = asset.src
110
+ img = asset.img
111
+ size = asset.size
112
+
113
+ value = { url:, path:, size:, img:, src: }
114
+
84
115
  hash[key] = value
85
116
  end
86
117
 
@@ -94,7 +125,9 @@ module Ro
94
125
  identifier:,
95
126
  type:,
96
127
  id:,
97
- urls:
128
+ urls:,
129
+ created_at:,
130
+ updated_at:,
98
131
  )
99
132
 
100
133
  @attributes.set(_meta: hash)
@@ -114,17 +147,49 @@ module Ro
114
147
  base = basename.split('.', 2).first
115
148
  key.push(base)
116
149
 
117
- if @attributes.has?(key)
118
- raise Error.new("#{ @path } clobbers #{ key.inspect }!")
150
+ value = _render(file)
151
+
152
+ if value.is_a?(HTML)
153
+ attrs = value.front_matter
154
+ update_attributes!(attrs, file:)
119
155
  end
120
156
 
121
- value = _render(file)
157
+ if @attributes.has?(key)
158
+ raise Error.new("path=#{ @path.inspect } masks #{ key.inspect } in #{ @attributes.inspect }!")
159
+ end
122
160
 
123
161
  @attributes.set(key => value)
124
162
  end
125
163
  end
126
164
 
165
+ def update_attributes!(attrs = {}, **context)
166
+ attrs = Map.for(attrs)
167
+
168
+ blacklist = %w[
169
+ assets
170
+ _meta
171
+ ]
172
+
173
+ blacklist.each do |key|
174
+ if attrs.has_key?(key)
175
+ Ro.error!("#{ key } is blacklisted!", **context)
176
+ end
177
+ end
178
+
179
+ keys = @attributes.depth_first_keys
180
+
181
+ attrs.depth_first_keys.each do |key|
182
+ if keys.include?(key)
183
+ Ro.error!("#{ attrs.inspect } clobbers #{ @attributes.inspect }!", **context)
184
+ end
185
+ end
186
+
187
+ @attributes.update(attrs)
188
+ end
189
+
190
+ # T028: Updated ignore patterns for new structure
127
191
  def _ignored_files
192
+ # Both old and new structure: ignore attributes files and assets/ subdirectory
128
193
  ignored_files =
129
194
  %w[
130
195
  attributes.yml
@@ -133,17 +198,23 @@ module Ro
133
198
  ./assets/**/**
134
199
  ].map do |glob|
135
200
  @path.glob(glob).select(&:file?)
136
- end
137
-
138
- ignored_files.flatten
201
+ end.flatten
139
202
  end
140
203
 
141
204
  def _render(file)
205
+ node = self
206
+
142
207
  value = Ro.render(file, _render_context)
143
208
 
144
- if value.is_a?(Ro::Template::HTML)
145
- html = value
146
- value = Ro.expand_asset_urls(html, self)
209
+ if value.is_a?(HTML)
210
+ front_matter = value.front_matter
211
+ html = Ro.expand_asset_urls(value, node)
212
+ value = HTML.new(html, front_matter:)
213
+ end
214
+
215
+ if value.is_a?(Hash)
216
+ attributes = value
217
+ value = Ro.expand_asset_values(attributes, node)
147
218
  end
148
219
 
149
220
  value
@@ -156,6 +227,10 @@ module Ro
156
227
  end
157
228
  end
158
229
 
230
+ def fetch(*args)
231
+ attributes.fetch(*args)
232
+ end
233
+
159
234
  def get(*args)
160
235
  attributes.get(*args)
161
236
  end
@@ -168,7 +243,10 @@ module Ro
168
243
  path.relative_to(root)
169
244
  end
170
245
 
246
+ # T027: Updated to return assets/ subdirectory in both old and new structure
171
247
  def asset_dir
248
+ # Both old and new structure use assets/ subdirectory
249
+ # This prevents files from being rendered as templates
172
250
  path.join('assets')
173
251
  end
174
252
 
@@ -219,14 +297,13 @@ module Ro
219
297
  end
220
298
 
221
299
  def url_for(relative_path, options = {})
222
- raise ArgumentError, relative_path if Path.absolute?(relative_path)
223
-
224
- fullpath = Path.for(path, relative_path).expand
225
- raise ArgumentError, "#{relative_path.inspect} -- DOES NOT EXIST" unless fullpath.exist?
226
-
227
300
  Ro.url_for(self.relative_path, relative_path, options)
228
301
  end
229
302
 
303
+ def path_for(...)
304
+ @path.join(...)
305
+ end
306
+
230
307
  def src_for(*args)
231
308
  key = Path.relative(:assets, :src, args).split('/')
232
309
  get(key)
@@ -250,6 +327,10 @@ module Ro
250
327
  to_json(...)
251
328
  end
252
329
 
330
+ def to_str(...)
331
+ to_json(...)
332
+ end
333
+
253
334
  def to_json(...)
254
335
  JSON.pretty_generate(to_hash, ...)
255
336
  end
@@ -262,12 +343,6 @@ module Ro
262
343
  to_hash.to_yaml(...)
263
344
  end
264
345
 
265
- def _mapify(data)
266
- converted = 'this_recursively_converts_nested_hashes_into_maps'
267
-
268
- Map.for(converted => data)[converted]
269
- end
270
-
271
346
  def files
272
347
  path.glob('**/**').select { |entry| entry.file? }.sort
273
348
  end
@@ -291,5 +366,13 @@ module Ro
291
366
 
292
367
  [position, published_at, created_at, name]
293
368
  end
369
+
370
+ def created_at
371
+ files.map{|file| File.stat(file).ctime}.min
372
+ end
373
+
374
+ def updated_at
375
+ files.map{|file| File.stat(file).mtime}.max
376
+ end
294
377
  end
295
378
  end
data/lib/ro/path.rb CHANGED
@@ -221,5 +221,9 @@ module Ro
221
221
  def <=>(other)
222
222
  sort_key <=> other.sort_key
223
223
  end
224
+
225
+ def stat
226
+ File.stat(self)
227
+ end
224
228
  end
225
229
  end
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