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,461 @@
1
+ # Contract: Migrator API
2
+
3
+ **Version**: 5.0.0
4
+ **Date**: 2025-10-17
5
+
6
+ ## Overview
7
+
8
+ Defines the interface for the migration tool that converts assets from the old structure (`identifier/attributes.yml` + `identifier/assets/`) to the new structure (`identifier.yml` + `identifier/`).
9
+
10
+ ## Command Line Interface
11
+
12
+ ### `ro migrate [PATH] [OPTIONS]`
13
+
14
+ **Purpose**: Migrate a collection or entire ro directory from old to new structure.
15
+
16
+ **Arguments**:
17
+ - `PATH` (optional): Path to collection or root directory (defaults to current directory)
18
+
19
+ **Options**:
20
+ - `--dry-run`: Preview migration without making changes
21
+ - `--backup [PATH]`: Create backup before migration (defaults to `PATH.backup.TIMESTAMP`)
22
+ - `--force`: Proceed even if backup exists or other warnings
23
+ - `--verbose`: Show detailed progress information
24
+ - `--rollback [BACKUP_PATH]`: Restore from a previous backup
25
+
26
+ **Examples**:
27
+ ```bash
28
+ # Migrate current directory (dry run)
29
+ ro migrate --dry-run
30
+
31
+ # Migrate specific collection with backup
32
+ ro migrate ./public/ro/posts --backup
33
+
34
+ # Migrate entire ro directory
35
+ ro migrate ./public/ro --verbose
36
+
37
+ # Rollback a migration
38
+ ro migrate --rollback ./public/ro.backup.20250117-120000
39
+ ```
40
+
41
+ **Exit Codes**:
42
+ - `0`: Success (all nodes migrated)
43
+ - `1`: Partial success (some nodes failed, see log)
44
+ - `2`: Fatal error (migration aborted, no changes made)
45
+ - `3`: Validation error (invalid structure, cannot migrate)
46
+
47
+ ---
48
+
49
+ ## Programmatic API
50
+
51
+ ### `Ro::Migrator.new(path, options = {})`
52
+
53
+ **Purpose**: Create a migrator instance for a given path.
54
+
55
+ **Parameters**:
56
+ - `path` (String | Pathname): Path to collection or root directory
57
+ - `options` (Hash, optional):
58
+ - `:dry_run` (Boolean): Preview mode (default: false)
59
+ - `:backup` (Boolean | String): Create backup, optionally at specific path (default: false)
60
+ - `:force` (Boolean): Skip safety checks (default: false)
61
+ - `:verbose` (Boolean): Enable detailed logging (default: false)
62
+ - `:logger` (Logger): Custom logger instance (default: STDOUT)
63
+
64
+ **Returns**: `Ro::Migrator` instance
65
+
66
+ **Example**:
67
+ ```ruby
68
+ migrator = Ro::Migrator.new('./public/ro/posts', dry_run: true, verbose: true)
69
+ ```
70
+
71
+ ---
72
+
73
+ ### `#migrate` → Ro::MigrationResult
74
+
75
+ **Purpose**: Execute the migration.
76
+
77
+ **Returns**: `Ro::MigrationResult` object with:
78
+ - `#success?` (Boolean): Whether migration completed successfully
79
+ - `#total_nodes` (Integer): Total nodes processed
80
+ - `#migrated_nodes` (Integer): Successfully migrated nodes
81
+ - `#failed_nodes` (Integer): Nodes that failed to migrate
82
+ - `#skipped_nodes` (Integer): Nodes already in new structure (skipped)
83
+ - `#errors` (Array<Hash>): Error details for failed nodes
84
+ - `#backup_path` (Pathname): Path to backup (if created)
85
+
86
+ **Raises**:
87
+ - `Ro::MigrationError`: If fatal error occurs during migration
88
+ - `Ro::ValidationError`: If path is invalid or structure is ambiguous
89
+
90
+ **Example**:
91
+ ```ruby
92
+ migrator = Ro::Migrator.new('./public/ro/posts', backup: true)
93
+ result = migrator.migrate
94
+
95
+ if result.success?
96
+ puts "Migrated #{result.migrated_nodes} nodes successfully"
97
+ else
98
+ puts "Migration failed: #{result.errors.count} errors"
99
+ result.errors.each do |error|
100
+ puts "#{error[:node_id]}: #{error[:message]}"
101
+ end
102
+ end
103
+ ```
104
+
105
+ ---
106
+
107
+ ### `#validate` → Boolean
108
+
109
+ **Purpose**: Validate that the path can be migrated without actually migrating.
110
+
111
+ **Returns**: Boolean (true if valid, false otherwise)
112
+
113
+ **Checks**:
114
+ - Path exists and is readable
115
+ - Path contains nodes in old structure
116
+ - No duplicate node IDs
117
+ - No permission issues
118
+ - Sufficient disk space (if backup enabled)
119
+
120
+ **Example**:
121
+ ```ruby
122
+ migrator = Ro::Migrator.new('./public/ro/posts')
123
+ if migrator.validate
124
+ puts "Ready to migrate"
125
+ else
126
+ puts "Validation failed: #{migrator.validation_errors.join(', ')}"
127
+ end
128
+ ```
129
+
130
+ ---
131
+
132
+ ### `#preview` → Array<Hash>
133
+
134
+ **Purpose**: Generate a preview of what will be migrated (dry run).
135
+
136
+ **Returns**: Array of hashes describing each migration step
137
+
138
+ **Example**:
139
+ ```ruby
140
+ migrator = Ro::Migrator.new('./public/ro/posts')
141
+ preview = migrator.preview
142
+
143
+ preview.each do |step|
144
+ puts "#{step[:action]}: #{step[:source]} → #{step[:destination]}"
145
+ end
146
+
147
+ # Output:
148
+ # MOVE: posts/my-post/attributes.yml → posts/my-post.yml
149
+ # MOVE: posts/my-post/assets/cover.jpg → posts/my-post/cover.jpg
150
+ # MOVE: posts/my-post/body.md → posts/my-post/body.md
151
+ # REMOVE: posts/my-post/ (empty directory)
152
+ ```
153
+
154
+ ---
155
+
156
+ ### `#rollback(backup_path)` → Boolean
157
+
158
+ **Purpose**: Restore from a backup created during migration.
159
+
160
+ **Parameters**:
161
+ - `backup_path` (String | Pathname): Path to backup directory
162
+
163
+ **Returns**: Boolean (true if rollback successful)
164
+
165
+ **Raises**:
166
+ - `Ro::RollbackError`: If backup is invalid or rollback fails
167
+
168
+ **Example**:
169
+ ```ruby
170
+ migrator = Ro::Migrator.new('./public/ro/posts')
171
+ result = migrator.migrate
172
+
173
+ if result.failed_nodes > 0
174
+ puts "Migration failed, rolling back..."
175
+ if migrator.rollback(result.backup_path)
176
+ puts "Rollback successful"
177
+ else
178
+ puts "Rollback failed!"
179
+ end
180
+ end
181
+ ```
182
+
183
+ ---
184
+
185
+ ## Migration Algorithm
186
+
187
+ ### Pre-Migration Phase
188
+
189
+ 1. **Validation**:
190
+ ```
191
+ For each potential node in path:
192
+ ✓ Check if directory contains attributes.yml (old structure)
193
+ ✓ Check if metadata file already exists (new structure - skip)
194
+ ✓ Check for duplicate IDs
195
+ ✓ Verify write permissions
196
+ ```
197
+
198
+ 2. **Backup** (if enabled):
199
+ ```
200
+ Create backup directory: {path}.backup.{timestamp}
201
+ Copy entire structure to backup using FileUtils.cp_r
202
+ Verify backup integrity (checksums)
203
+ ```
204
+
205
+ 3. **Plan Migration**:
206
+ ```
207
+ For each node in old structure:
208
+ - Identify: {identifier}/attributes.yml
209
+ - Plan: Move attributes.yml → {identifier}.yml
210
+ - Plan: Move {identifier}/assets/* → {identifier}/*
211
+ - Plan: Move {identifier}/* (non-assets) → {identifier}/*
212
+ - Plan: Remove {identifier}/assets/ (empty)
213
+ - Plan: Remove {identifier}/ (if empty after above)
214
+ ```
215
+
216
+ ---
217
+
218
+ ### Migration Phase
219
+
220
+ For each node (in dependency order):
221
+
222
+ 1. **Create Metadata File**:
223
+ ```ruby
224
+ source = "#{identifier}/attributes.yml"
225
+ dest = "#{identifier}.yml"
226
+
227
+ FileUtils.mv(source, dest)
228
+ verify_file(dest)
229
+ ```
230
+
231
+ 2. **Move Asset Files**:
232
+ ```ruby
233
+ source_dir = "#{identifier}/assets/"
234
+ dest_dir = "#{identifier}/"
235
+
236
+ if source_dir.exist?
237
+ # Move all files from assets/ to identifier/
238
+ source_dir.children.each do |child|
239
+ dest_path = dest_dir / child.basename
240
+ FileUtils.mv(child, dest_path)
241
+ verify_file(dest_path)
242
+ end
243
+
244
+ # Remove empty assets/ directory
245
+ source_dir.rmdir
246
+ end
247
+ ```
248
+
249
+ 3. **Move Other Content Files**:
250
+ ```ruby
251
+ # Files like body.md, samples/, etc. already in identifier/
252
+ # These stay in place (already at correct location)
253
+ ```
254
+
255
+ 4. **Cleanup**:
256
+ ```ruby
257
+ # If identifier/ directory is now empty, remove it
258
+ # (This only happens if node had ONLY attributes.yml, no other files)
259
+ if "#{identifier}/".children.empty?
260
+ "#{identifier}/".rmdir
261
+ end
262
+ ```
263
+
264
+ 5. **Verification**:
265
+ ```ruby
266
+ # Verify node can be loaded in new structure
267
+ node = Ro::Node.new(collection, "#{identifier}.yml")
268
+ assert node.attributes.any?, "Metadata loaded successfully"
269
+ assert node.asset_paths.sort == original_asset_paths.sort, "All assets present"
270
+ ```
271
+
272
+ ---
273
+
274
+ ### Post-Migration Phase
275
+
276
+ 1. **Verify All Nodes**:
277
+ ```
278
+ For each migrated node:
279
+ ✓ Metadata file exists at correct location
280
+ ✓ All assets are accessible
281
+ ✓ Node can be loaded via Collection API
282
+ ```
283
+
284
+ 2. **Cleanup Old Structure**:
285
+ ```
286
+ For each migrated node:
287
+ ✓ Verify old attributes.yml is gone
288
+ ✓ Verify old assets/ directory is gone
289
+ ✓ Verify old node directory is gone (if was emptied)
290
+ ```
291
+
292
+ 3. **Generate Report**:
293
+ ```ruby
294
+ {
295
+ total_nodes: 50,
296
+ migrated_nodes: 48,
297
+ failed_nodes: 2,
298
+ skipped_nodes: 5, # Already in new structure
299
+ errors: [
300
+ { node_id: 'broken-post', message: 'Invalid YAML in attributes.yml' },
301
+ { node_id: 'locked-post', message: 'Permission denied' }
302
+ ],
303
+ backup_path: './public/ro/posts.backup.20250117-120000'
304
+ }
305
+ ```
306
+
307
+ ---
308
+
309
+ ## Error Handling
310
+
311
+ ### Recoverable Errors
312
+
313
+ **Scenario**: Individual node fails (e.g., permission error)
314
+
315
+ **Behavior**:
316
+ - Log error with node ID and details
317
+ - Continue with next node
318
+ - Report error in final result
319
+ - Do NOT rollback (partial migration is acceptable)
320
+
321
+ **Example**:
322
+ ```ruby
323
+ # Node 1: Success
324
+ # Node 2: Failed (permission error) ← Log and continue
325
+ # Node 3: Success
326
+ # ...
327
+ # Report: 48/50 migrated, 2 failed
328
+ ```
329
+
330
+ ---
331
+
332
+ ### Fatal Errors
333
+
334
+ **Scenario**: Catastrophic failure (e.g., disk full, backup failed)
335
+
336
+ **Behavior**:
337
+ - Halt migration immediately
338
+ - Attempt rollback to backup (if exists)
339
+ - Exit with error code 2
340
+ - Preserve backup for manual recovery
341
+
342
+ **Example**:
343
+ ```ruby
344
+ # Node 1: Success
345
+ # Node 2: Disk full! ← Fatal error
346
+ # → Attempt rollback
347
+ # → Restore from backup
348
+ # → Exit code 2
349
+ ```
350
+
351
+ ---
352
+
353
+ ### Validation Errors
354
+
355
+ **Scenario**: Pre-migration validation fails
356
+
357
+ **Behavior**:
358
+ - Do NOT start migration
359
+ - Report all validation errors
360
+ - Exit with error code 3
361
+ - No rollback needed (no changes made)
362
+
363
+ **Example**:
364
+ ```ruby
365
+ # Validation:
366
+ # ✗ Duplicate node ID: "my-post" (both my-post.yml and my-post.json exist)
367
+ # ✗ Insufficient disk space for backup
368
+ # → Exit code 3, no changes made
369
+ ```
370
+
371
+ ---
372
+
373
+ ## Test Requirements
374
+
375
+ ### Unit Tests
376
+
377
+ Must verify:
378
+
379
+ 1. **Initialization**:
380
+ - ✓ Creates migrator with valid path
381
+ - ✓ Applies options (dry_run, backup, force, verbose)
382
+ - ✓ Raises error for invalid path
383
+
384
+ 2. **Validation**:
385
+ - ✓ Detects old structure correctly
386
+ - ✓ Detects new structure correctly
387
+ - ✓ Detects mixed structures (error)
388
+ - ✓ Detects duplicate node IDs
389
+ - ✓ Checks write permissions
390
+ - ✓ Validates sufficient disk space
391
+
392
+ 3. **Migration**:
393
+ - ✓ Moves attributes.yml correctly
394
+ - ✓ Moves assets/ files correctly
395
+ - ✓ Preserves other content files
396
+ - ✓ Removes empty directories
397
+ - ✓ Handles nested asset directories
398
+ - ✓ Preserves file timestamps and permissions
399
+
400
+ 4. **Backup**:
401
+ - ✓ Creates backup before migration
402
+ - ✓ Backup contains complete copy of original
403
+ - ✓ Backup path is timestamped correctly
404
+
405
+ 5. **Rollback**:
406
+ - ✓ Restores from backup correctly
407
+ - ✓ Removes partial migration artifacts
408
+ - ✓ Validates backup before restoring
409
+
410
+ 6. **Error Handling**:
411
+ - ✓ Continues on recoverable errors
412
+ - ✓ Halts on fatal errors
413
+ - ✓ Logs errors with details
414
+ - ✓ Generates accurate error reports
415
+
416
+ ### Integration Tests
417
+
418
+ Must verify end-to-end migration:
419
+
420
+ 1. **Full Collection Migration**:
421
+ - ✓ Migrate collection with 10+ nodes
422
+ - ✓ All nodes accessible via new Collection API
423
+ - ✓ All assets accessible via new Node API
424
+ - ✓ Old structure completely removed
425
+
426
+ 2. **Partial Migration**:
427
+ - ✓ Some nodes succeed, some fail
428
+ - ✓ Successful nodes are in new structure
429
+ - ✓ Failed nodes remain in old structure (if safe)
430
+ - ✓ Errors reported accurately
431
+
432
+ 3. **Edge Cases**:
433
+ - ✓ Metadata-only node (no assets/)
434
+ - ✓ Assets-only node (no attributes.yml) - handle gracefully
435
+ - ✓ Node with nested asset subdirectories
436
+ - ✓ Node with non-asset files (body.md, samples/, etc.)
437
+ - ✓ Empty collection (no nodes)
438
+
439
+ 4. **Rollback**:
440
+ - ✓ Failed migration triggers rollback
441
+ - ✓ Post-rollback structure matches pre-migration
442
+ - ✓ All data preserved during rollback
443
+
444
+ ---
445
+
446
+ ## Success Criteria
447
+
448
+ Per spec SC-002: Migration must complete without data loss for 100% of assets tested.
449
+
450
+ **Verification**:
451
+ 1. Count files before migration: N
452
+ 2. Run migration
453
+ 3. Count files after migration: M
454
+ 4. Assert: M == N (no files lost)
455
+ 5. Verify: All files accessible via new API
456
+
457
+ **Additional Checks**:
458
+ - File checksums match before/after (content unchanged)
459
+ - File permissions preserved
460
+ - Directory structure simplified (nesting depth reduced by 1)
461
+ - No orphaned files or directories