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
data/lib/ro/asset.rb CHANGED
@@ -2,14 +2,6 @@ module Ro
2
2
  class Asset < ::String
3
3
  include Klass
4
4
 
5
- DEFAULT_IMAGE_PATTERNS = [
6
- /[.](webp|jpg|jpeg|png|gif|tif|tiff|svg)$/i
7
- ]
8
-
9
- def Asset.image_patterns
10
- @image_patterns ||= DEFAULT_IMAGE_PATTERNS.dup
11
- end
12
-
13
5
  attr_reader :path, :node, :relative_path, :name, :url
14
6
 
15
7
  def initialize(arg, *args)
@@ -17,9 +9,48 @@ module Ro
17
9
 
18
10
  @path = Path.for(arg, *args)
19
11
 
20
- @node = options.fetch(:node) { Node.for(@path.split('/assets/').first) }
12
+ # T029: Updated to split on node ID instead of /assets/ segment
13
+ @node = options.fetch(:node) do
14
+ # Try to find node by splitting path
15
+ # In new structure: no /assets/ segment
16
+ # In old structure: /assets/ segment exists
17
+ if @path.to_s.include?('/assets/')
18
+ # Old structure
19
+ Node.for(@path.split('/assets/').first)
20
+ else
21
+ # New structure: find node directory by looking at parent paths
22
+ # Asset path like: /collection/node-id/file.jpg
23
+ # Node path should be: /collection/node-id
24
+ found_node = nil
25
+ node_path = @path.parent
26
+ while node_path && !node_path.basename.to_s.match(/\.(yml|yaml|json|toml)$/)
27
+ # Check if there's a metadata file for this directory
28
+ collection_path = node_path.parent
29
+ node_id = node_path.basename.to_s
30
+
31
+ %w[yml yaml json toml].each do |ext|
32
+ metadata_file = collection_path.join("#{node_id}.#{ext}")
33
+ if metadata_file.exist?
34
+ root = Root.for(collection_path.parent)
35
+ collection = root.collection_for(collection_path)
36
+ found_node = Node.new(collection, metadata_file)
37
+ break
38
+ end
39
+ end
21
40
 
22
- @relative_path = @path.relative_to(@node.path)
41
+ break if found_node
42
+ node_path = node_path.parent
43
+ end
44
+
45
+ # Fallback: old behavior
46
+ found_node || Node.for(@path.split('/assets/').first)
47
+ end
48
+ end
49
+
50
+ # T030: Updated relative_path calculation for new structure
51
+ # In new structure, path is already relative to node.asset_dir
52
+ # In old structure, need to account for assets/ prefix
53
+ @relative_path = @path.relative_to(@node.asset_dir)
23
54
 
24
55
  @name = @relative_path
25
56
 
@@ -28,18 +59,38 @@ module Ro
28
59
  super(@path)
29
60
  end
30
61
 
31
- def image?
32
- @path.file? && Asset.image_patterns.any? { |pattern| pattern === @path.basename }
62
+ def is_img?
63
+ @path.file? && Ro.is_image?(@path.basename)
33
64
  end
34
65
 
35
- def src
66
+ alias is_img is_img?
67
+
68
+ def img
69
+ return unless is_img?
70
+ Ro.image_info(path.to_s)
71
+ end
72
+
73
+ def is_src?
36
74
  key = relative_path.parts
37
- subdir = key.size > 2 ? key[1] : nil
38
- is_src = subdir == 'src'
75
+ # Check if the first directory in the path is 'src'
76
+ # e.g., src/file.js or src/subdir/file.js
77
+ first_dir = key.size >= 2 ? key[0] : nil
78
+ !!(first_dir == 'src')
79
+ end
39
80
 
40
- return unless is_src
81
+ alias is_src is_src?
41
82
 
83
+ def src
84
+ return unless is_src?
42
85
  Ro.render_src(path, node)
43
86
  end
87
+
88
+ def stat
89
+ @path.stat.size
90
+ end
91
+
92
+ def size
93
+ stat.size
94
+ end
44
95
  end
45
96
  end
data/lib/ro/collection.rb CHANGED
@@ -34,6 +34,20 @@ module Ro
34
34
  Node.new(path)
35
35
  end
36
36
 
37
+ # T021: Scan for metadata files in new structure format
38
+ def metadata_files
39
+ extensions = %w[yml yaml json toml]
40
+ files = []
41
+
42
+ extensions.each do |ext|
43
+ @path.glob("*.#{ext}").each do |file|
44
+ files << file if file.file?
45
+ end
46
+ end
47
+
48
+ files.sort
49
+ end
50
+
37
51
  def subdirectories(...)
38
52
  @path.subdirectories(...)
39
53
  end
@@ -42,13 +56,61 @@ module Ro
42
56
  @path.subdirectory_for(name)
43
57
  end
44
58
 
45
- def each(&block)
46
- accum = []
59
+ # T020: Modified to discover nodes from metadata files (new structure)
60
+ def each(offset:nil, limit:nil, &block)
61
+ # Return enumerator if no block given and no offset/limit
62
+ return to_enum(:each, offset: offset, limit: limit) unless block_given?
63
+
64
+ # Use metadata files for new structure instead of subdirectories
65
+ files = metadata_files
66
+
67
+ if offset
68
+ i = -1
69
+ n = 0
70
+ files.each do |metadata_file|
71
+ i += 1
72
+ next if i < offset
73
+ node = Node.new(self, metadata_file)
74
+ block.call(node)
75
+ n += 1
76
+ break if limit && n >= limit
77
+ end
78
+ else
79
+ files.each do |metadata_file|
80
+ node = Node.new(self, metadata_file)
81
+ block.call(node)
82
+ end
83
+ end
84
+
85
+ self
86
+ end
87
+
88
+ class Page < ::Array
89
+ attr_accessor :number
47
90
 
48
- subdirectories do |subdirectory|
49
- node = node_for(subdirectory)
91
+ def initialize(nodes = [], number: 1)
92
+ replace(nodes)
93
+ @number = number
94
+ end
95
+ end
96
+
97
+ def page(number, size: 10)
98
+ offset = [(number - 1), 0].max * size
99
+ limit = [size, 1].max
100
+
101
+ nodes = each(offset:, limit:)
102
+ Page.new(nodes, number:)
103
+ end
104
+
105
+ def paginate(size: 10, &block)
106
+ number = 0
107
+ accum = []
50
108
 
51
- block ? block.call(node) : accum.push(node)
109
+ loop do
110
+ number += 1
111
+ page = self.page(number, size:)
112
+ break if page.empty?
113
+ block ? block.call(page) : accum.push(page)
52
114
  end
53
115
 
54
116
  block ? self : accum
@@ -105,8 +167,10 @@ module Ro
105
167
  block ? self : accum
106
168
  end
107
169
 
108
- def to_array(...)
109
- each(...)
170
+ def to_array(offset: nil, limit: nil)
171
+ accum = []
172
+ each(offset: offset, limit: limit) { |node| accum << node }
173
+ accum
110
174
  end
111
175
 
112
176
  alias to_a to_array
@@ -143,11 +207,28 @@ module Ro
143
207
  ]
144
208
  end
145
209
 
210
+ # T022: Modified to find nodes by metadata filename
146
211
  def get(name)
147
- paths_for(name).each do |path|
148
- next unless path.exist?
212
+ # Try to find metadata file for this node ID
213
+ extensions = %w[yml yaml json toml]
214
+ extensions.each do |ext|
215
+ metadata_file = @path.join("#{name}.#{ext}")
216
+ if metadata_file.exist? && metadata_file.file?
217
+ return Node.new(self, metadata_file)
218
+ end
219
+ end
149
220
 
150
- return node_for(path)
221
+ # Also try with slugified versions
222
+ [
223
+ Slug.for(name, :join => '-'),
224
+ Slug.for(name, :join => '_')
225
+ ].each do |slug|
226
+ extensions.each do |ext|
227
+ metadata_file = @path.join("#{slug}.#{ext}")
228
+ if metadata_file.exist? && metadata_file.file?
229
+ return Node.new(self, metadata_file)
230
+ end
231
+ end
151
232
  end
152
233
 
153
234
  nil
data/lib/ro/config.rb CHANGED
@@ -11,6 +11,9 @@ module Ro
11
11
  :url =>
12
12
  (Ro.env.url || Ro.defaults.url),
13
13
 
14
+ :img_url =>
15
+ (Ro.env.img_url || Ro.defaults.img_url),
16
+
14
17
  :page_size =>
15
18
  (Ro.env.page_size || Ro.defaults.page_size),
16
19
 
@@ -48,6 +51,7 @@ module Ro
48
51
  :root => :root,
49
52
  :build => :path,
50
53
  :url => :url,
54
+ :img_url => :url,
51
55
  :page_size => :int,
52
56
  :log => :bool,
53
57
  :debug => :bool,
data/lib/ro/error.rb CHANGED
@@ -1,8 +1,11 @@
1
1
  module Ro
2
2
  class Error < ::StandardError
3
- def initialize(message, context = nil)
4
- super(message)
3
+ attr_reader :context
4
+
5
+ def initialize(message, **context)
5
6
  @context = context
7
+ msg = context.empty? ? "#{ message }" : "#{ message }, #{ context.inspect }"
8
+ super(msg)
6
9
  end
7
10
  end
8
11
  end
data/lib/ro/html.rb ADDED
@@ -0,0 +1,23 @@
1
+ module Ro
2
+ require_relative 'html_safe'
3
+
4
+ class HTML < ::ActiveSupport::SafeBuffer
5
+ def initialize(*args, **kws, &block)
6
+ self.front_matter = kws.fetch(:front_matter){ {} }
7
+
8
+ super(args.join)
9
+ end
10
+
11
+ def front_matter
12
+ @front_matter ||= Map.new
13
+ end
14
+
15
+ def front_matter=(hash = {})
16
+ @front_matter = Map.for(hash)
17
+ end
18
+
19
+ def attributes
20
+ front_matter
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ unless defined?(ActiveSupport::SafeBuffer)
4
+
5
+ class Object
6
+ def html_safe?
7
+ false
8
+ end
9
+ end
10
+
11
+ class Numeric
12
+ def html_safe?
13
+ true
14
+ end
15
+ end
16
+
17
+ module ActiveSupport #:nodoc:
18
+ class SafeBuffer < String
19
+ UNSAFE_STRING_METHODS = %w(
20
+ capitalize chomp chop delete downcase gsub lstrip next reverse rstrip
21
+ slice squeeze strip sub succ swapcase tr tr_s upcase
22
+ )
23
+
24
+ alias_method :original_concat, :concat
25
+ private :original_concat
26
+
27
+ # Raised when <tt>ActiveSupport::SafeBuffer#safe_concat</tt> is called on unsafe buffers.
28
+ class SafeConcatError < StandardError
29
+ def initialize
30
+ super "Could not concatenate to the buffer because it is not html safe."
31
+ end
32
+ end
33
+
34
+ def [](*args)
35
+ if args.size < 2
36
+ super
37
+ elsif html_safe?
38
+ new_safe_buffer = super
39
+
40
+ if new_safe_buffer
41
+ new_safe_buffer.instance_variable_set :@html_safe, true
42
+ end
43
+
44
+ new_safe_buffer
45
+ else
46
+ to_str[*args]
47
+ end
48
+ end
49
+
50
+ def safe_concat(value)
51
+ raise SafeConcatError unless html_safe?
52
+ original_concat(value)
53
+ end
54
+
55
+ def initialize(str = "")
56
+ @html_safe = true
57
+ super
58
+ end
59
+
60
+ def initialize_copy(other)
61
+ super
62
+ @html_safe = other.html_safe?
63
+ end
64
+
65
+ def clone_empty
66
+ self[0, 0]
67
+ end
68
+
69
+ def concat(value)
70
+ super(html_escape_interpolated_argument(value))
71
+ end
72
+ alias << concat
73
+
74
+ def prepend(value)
75
+ super(html_escape_interpolated_argument(value))
76
+ end
77
+
78
+ def +(other)
79
+ dup.concat(other)
80
+ end
81
+
82
+ def %(args)
83
+ case args
84
+ when Hash
85
+ escaped_args = Hash[args.map { |k, arg| [k, html_escape_interpolated_argument(arg)] }]
86
+ else
87
+ escaped_args = Array(args).map { |arg| html_escape_interpolated_argument(arg) }
88
+ end
89
+
90
+ self.class.new(super(escaped_args))
91
+ end
92
+
93
+ def html_safe?
94
+ defined?(@html_safe) && @html_safe
95
+ end
96
+
97
+ def to_s
98
+ self
99
+ end
100
+
101
+ def to_param
102
+ to_str
103
+ end
104
+
105
+ def encode_with(coder)
106
+ coder.represent_object nil, to_str
107
+ end
108
+
109
+ UNSAFE_STRING_METHODS.each do |unsafe_method|
110
+ if unsafe_method.respond_to?(unsafe_method)
111
+ class_eval <<-EOT, __FILE__, __LINE__ + 1
112
+ def #{unsafe_method}(*args, &block) # def capitalize(*args, &block)
113
+ to_str.#{unsafe_method}(*args, &block) # to_str.capitalize(*args, &block)
114
+ end # end
115
+
116
+ def #{unsafe_method}!(*args) # def capitalize!(*args)
117
+ @html_safe = false # @html_safe = false
118
+ super # super
119
+ end # end
120
+ EOT
121
+ end
122
+ end
123
+
124
+ private
125
+
126
+ def html_escape_interpolated_argument(arg)
127
+ (!html_safe? || arg.html_safe?) ? arg : CGI.escapeHTML(arg.to_s)
128
+ end
129
+ end
130
+ end
131
+
132
+ class String
133
+ # Marks a string as trusted safe. It will be inserted into HTML with no
134
+ # additional escaping performed. It is your responsibility to ensure that the
135
+ # string contains no malicious content. This method is equivalent to the
136
+ # +raw+ helper in views. It is recommended that you use +sanitize+ instead of
137
+ # this method. It should never be called on user input.
138
+ def html_safe
139
+ ActiveSupport::SafeBuffer.new(self)
140
+ end
141
+ end
142
+
143
+ end
data/lib/ro/methods.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  module Ro
2
- module Methods
2
+ module Methods
3
3
  # cast methods
4
4
  # |
5
5
  # v
@@ -51,10 +51,19 @@ module Ro
51
51
  # v
52
52
  def url_for(path, *args)
53
53
  options = Map.extract_options!(args)
54
- base = options[:base] || options[:url] || Ro.config.url
54
+
55
+ base = (options.delete(:base) || options.delete(:url))
55
56
 
56
57
  path = Path.for(path, *args)
57
58
 
59
+ base ||= (
60
+ if Ro.is_image?(path)
61
+ Ro.config.img_url
62
+ else
63
+ Ro.config.url
64
+ end
65
+ )
66
+
58
67
  fragment = options.delete(:fragment)
59
68
  query = options.delete(:query) || options
60
69
 
@@ -63,8 +72,7 @@ module Ro
63
72
  uri.path = '' if uri.path == '/'
64
73
 
65
74
  uri.query = query_string_for(query) unless query.empty?
66
-
67
- uri.fragment = fragment if fragment
75
+ uri.fragment = fragment unless fragment.nil?
68
76
 
69
77
  uri.to_s
70
78
  end
@@ -127,8 +135,8 @@ module Ro
127
135
  end
128
136
  end
129
137
 
130
- def error!(message, context = nil)
131
- error = Error.new(message, context)
138
+ def error!(message, **context)
139
+ error = Error.new(message, **context)
132
140
 
133
141
  begin
134
142
  raise error
@@ -139,6 +147,14 @@ module Ro
139
147
  end
140
148
  end
141
149
 
150
+ def emsg(e)
151
+ if e.is_a?(Exception)
152
+ "#{ e.message } (#{ e.class.name })\n#{ Array(e.backtrace).join(10.chr) }"
153
+ else
154
+ e.to_s
155
+ end
156
+ end
157
+
142
158
  # template methods
143
159
  # |
144
160
  # v
@@ -158,7 +174,8 @@ module Ro
158
174
  # |
159
175
  # v
160
176
  EXPAND_ASSET_URL_STRATEGIES = %i[
161
- accurate_expand_asset_urls sloppy_expand_asset_urls
177
+ accurate_expand_asset_urls
178
+ sloppy_expand_asset_urls
162
179
  ]
163
180
 
164
181
  def expand_asset_url_strategies
@@ -166,55 +183,58 @@ module Ro
166
183
  end
167
184
 
168
185
  def expand_asset_urls(html, node)
169
- last = expand_asset_url_strategies.size - 1
186
+ strategies = expand_asset_url_strategies
187
+ error = nil
170
188
 
171
- expand_asset_url_strategies.each_with_index do |strategy, i|
189
+ strategies.each do |strategy|
172
190
  return send(strategy, html, node)
173
191
  rescue Object => e
174
- raise if i == last
175
-
176
- Ro.log(e)
192
+ error = e
193
+ Ro.log(:error, emsg(error))
194
+ Ro.log(:error, "failed to expand assets via #{ strategy }")
177
195
  end
178
196
 
179
- Ro.error! "could not expand assets via #{expand_asset_url_strategies.join(' | ')}"
197
+ raise error
180
198
  end
181
199
 
182
200
  def accurate_expand_asset_urls(html, node)
183
- doc = REXML::Document.new('<__ro__>' + html + '</__ro__>')
201
+ doc = Nokogiri::HTML.fragment(html.to_str)
184
202
 
185
- doc.each_recursive do |element|
186
- next unless element.respond_to?(:attributes)
187
-
188
- src = {}
189
- element.attributes.each do |key, value|
190
- src[key] = value
191
- end
192
-
193
- dst = expand_asset_values(src, node)
194
-
195
- dst.each do |k, v|
196
- element.attributes[k] = v
203
+ doc.traverse do |element|
204
+ if element.respond_to?(:attributes)
205
+ attributes = element.attributes
206
+ expand_asset_values!(attributes) unless attributes.empty?
197
207
  end
198
208
  end
199
209
 
200
- doc.to_s.tap do |xml|
201
- xml.sub!(/^\s*<.?__ro__>\s*/, '')
202
- xml.sub!(/\s*<.?__ro__>\s*$/, '')
203
- xml.strip!
204
- end
210
+ expanded = doc.to_s.strip
211
+
212
+ HTML.new(expanded)
205
213
  end
206
214
 
207
215
  def sloppy_expand_asset_urls(html, node)
208
- html.to_s.gsub(%r{\s*=\s*['"](?:[.]/)?assets/[^'"\s]+['"]}) do |match|
209
- path = match[%r{assets/[^'"\s]+}]
210
- url = node.url_for(path)
211
- "='#{url}'"
212
- end
216
+ re = %r`\s*=\s*['"](?:[.]/)?(assets/[^'"\s]+)['"]`
217
+
218
+ expanded =
219
+ html.to_str.gsub(re) do |match|
220
+ path = match[%r`assets/[^'"\s]+`]
221
+
222
+ if node.path_for(path).exist?
223
+ url = node.url_for(path)
224
+ "='#{url}'"
225
+ else
226
+ match
227
+ end
228
+ end
229
+
230
+ HTML.new(expanded)
213
231
  end
214
232
 
215
- def expand_asset_values(hash, node)
233
+ def expand_asset_values(hash, node)
216
234
  src = Map.for(hash)
217
- dst = Map.new
235
+ dst = Map.new(hash)
236
+
237
+ return dst if src.empty?
218
238
 
219
239
  re = %r{\A(?:[.]/)?(assets/[^\s]+)\s*\z}
220
240
 
@@ -232,6 +252,43 @@ module Ro
232
252
 
233
253
  dst.to_hash
234
254
  end
255
+
256
+ def expand_asset_values!(hash, node)
257
+ expand_asset_values(hash, node).each do |key, value|
258
+ hash[key] = value
259
+ end
260
+ end
261
+
262
+ #
263
+ DEFAULT_IMAGE_EXTENSIONS = %i[
264
+ webp jpg jpeg png gif tif tiff svg
265
+ ]
266
+
267
+ DEFAULT_IMAGE_PATTERNS = [
268
+ /[.](#{ DEFAULT_IMAGE_EXTENSIONS.join('|') })$/i
269
+ ]
270
+
271
+ def image_patterns
272
+ @image_patterns ||= DEFAULT_IMAGE_PATTERNS.dup
273
+ end
274
+
275
+ def image_pattern
276
+ Regexp.union(Ro.image_patterns)
277
+ end
278
+
279
+ def is_image?(path)
280
+ !!(URI.parse(path.to_s).path =~ Ro.image_pattern)
281
+ end
282
+
283
+ def image_info(path)
284
+ is = ImageSize.path(path)
285
+ format, width, height = is.format.to_s, is.width, is.height
286
+ {format:, width:, height:}
287
+ end
288
+
289
+ def uuid
290
+ SecureRandom.uuid_v7.to_s
291
+ end
235
292
  end
236
293
 
237
294
  extend Methods