ro 4.4.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 (121) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +42 -16
  3. data/MIGRATION.md +320 -0
  4. data/README.md +30 -18
  5. data/a.yml +60 -0
  6. data/bin/ro +10 -0
  7. data/lib/ro/_lib.rb +1 -1
  8. data/lib/ro/asset.rb +46 -5
  9. data/lib/ro/collection.rb +51 -13
  10. data/lib/ro/migrator.rb +285 -0
  11. data/lib/ro/node.rb +53 -13
  12. data/lib/ro/root.rb +75 -1
  13. data/lib/ro/script/migrate.rb +204 -0
  14. data/lib/ro/script/server.rb +1 -1
  15. data/lib/ro.rb +1 -0
  16. data/ro.gemspec +207 -16
  17. data/specs/001-simplify-asset-structure/IMPLEMENTATION_SUMMARY.md +212 -0
  18. data/specs/001-simplify-asset-structure/checklists/requirements.md +36 -0
  19. data/specs/001-simplify-asset-structure/contracts/collection_api.md +407 -0
  20. data/specs/001-simplify-asset-structure/contracts/migrator_api.md +461 -0
  21. data/specs/001-simplify-asset-structure/contracts/node_api.md +294 -0
  22. data/specs/001-simplify-asset-structure/data-model.md +381 -0
  23. data/specs/001-simplify-asset-structure/plan.md +90 -0
  24. data/specs/001-simplify-asset-structure/quickstart.md +575 -0
  25. data/specs/001-simplify-asset-structure/research.md +333 -0
  26. data/specs/001-simplify-asset-structure/spec.md +127 -0
  27. data/specs/001-simplify-asset-structure/tasks.md +349 -0
  28. data/test/fixtures/new_structure/mixed/test-json.json +5 -0
  29. data/test/fixtures/new_structure/mixed/test-yaml.yml +3 -0
  30. data/test/fixtures/new_structure/posts/metadata-only.yml +7 -0
  31. data/test/fixtures/new_structure/posts/nested-test/assets/subdirectory/image.png +2 -0
  32. data/test/fixtures/new_structure/posts/nested-test.yml +7 -0
  33. data/test/fixtures/new_structure/posts/sample-post/assets/body.md +5 -0
  34. data/test/fixtures/new_structure/posts/sample-post/assets/image.jpg +2 -0
  35. data/test/fixtures/new_structure/posts/sample-post.yml +7 -0
  36. data/test/fixtures/old_structure/posts/assets-only/assets/test.txt +1 -0
  37. data/test/fixtures/old_structure/posts/sample-post/assets/body.md +5 -0
  38. data/test/fixtures/old_structure/posts/sample-post/assets/image.jpg +2 -0
  39. data/test/fixtures/old_structure/posts/sample-post/attributes.yml +2 -0
  40. data/test/integration/ro_integration_test.rb +165 -0
  41. data/test/test_helper.rb +149 -0
  42. data/test/tmp/migration_test_1760746513.backup.20251018001513/migration_test_1760746513/posts/sample-post/assets/image.jpg +2 -0
  43. data/test/tmp/migration_test_1760746513.backup.20251018001513/migration_test_1760746513/posts/sample-post/attributes.yml +7 -0
  44. data/test/tmp/migration_test_1760746513.backup.20251018001513/migration_test_1760746513/posts/sample-post/body.md +5 -0
  45. data/test/tmp/migration_test_1760746513.backup.20251018001513/posts/sample-post/assets/image.jpg +2 -0
  46. data/test/tmp/migration_test_1760746513.backup.20251018001513/posts/sample-post/attributes.yml +7 -0
  47. data/test/tmp/migration_test_1760746513.backup.20251018001513/posts/sample-post/body.md +5 -0
  48. data/test/tmp/migration_test_1760746556.backup.20251018001556/migration_test_1760746556/posts/sample-post/assets/image.jpg +2 -0
  49. data/test/tmp/migration_test_1760746556.backup.20251018001556/migration_test_1760746556/posts/sample-post/attributes.yml +7 -0
  50. data/test/tmp/migration_test_1760746556.backup.20251018001556/migration_test_1760746556/posts/sample-post/body.md +5 -0
  51. data/test/tmp/migration_test_1760746556.backup.20251018001556/posts/sample-post/assets/image.jpg +2 -0
  52. data/test/tmp/migration_test_1760746556.backup.20251018001556/posts/sample-post/attributes.yml +7 -0
  53. data/test/tmp/migration_test_1760746556.backup.20251018001556/posts/sample-post/body.md +5 -0
  54. data/test/tmp/migration_test_1760755248.backup.20251018024048/migration_test_1760755248/posts/sample-post/assets/image.jpg +2 -0
  55. data/test/tmp/migration_test_1760755248.backup.20251018024048/migration_test_1760755248/posts/sample-post/attributes.yml +7 -0
  56. data/test/tmp/migration_test_1760755248.backup.20251018024048/migration_test_1760755248/posts/sample-post/body.md +5 -0
  57. data/test/tmp/migration_test_1760755248.backup.20251018024048/posts/sample-post/assets/image.jpg +2 -0
  58. data/test/tmp/migration_test_1760755248.backup.20251018024048/posts/sample-post/attributes.yml +7 -0
  59. data/test/tmp/migration_test_1760755248.backup.20251018024048/posts/sample-post/body.md +5 -0
  60. data/test/tmp/migration_test_1760758803.backup.20251018034003/migration_test_1760758803/posts/sample-post/body.md +5 -0
  61. data/test/tmp/migration_test_1760758803.backup.20251018034003/migration_test_1760758803/posts/sample-post/image.jpg +2 -0
  62. data/test/tmp/migration_test_1760758803.backup.20251018034003/migration_test_1760758803/posts/sample-post.yml +7 -0
  63. data/test/tmp/migration_test_1760758803.backup.20251018034003/posts/sample-post/body.md +5 -0
  64. data/test/tmp/migration_test_1760758803.backup.20251018034003/posts/sample-post/image.jpg +2 -0
  65. data/test/tmp/migration_test_1760758803.backup.20251018034003/posts/sample-post.yml +7 -0
  66. data/test/tmp/migration_test_1760758869.backup.20251018034109/migration_test_1760758869/posts/sample-post/assets/body.md +5 -0
  67. data/test/tmp/migration_test_1760758869.backup.20251018034109/migration_test_1760758869/posts/sample-post/assets/image.jpg +2 -0
  68. data/test/tmp/migration_test_1760758869.backup.20251018034109/migration_test_1760758869/posts/sample-post/attributes.yml +2 -0
  69. data/test/tmp/migration_test_1760758869.backup.20251018034109/posts/sample-post/assets/body.md +5 -0
  70. data/test/tmp/migration_test_1760758869.backup.20251018034109/posts/sample-post/assets/image.jpg +2 -0
  71. data/test/tmp/migration_test_1760758869.backup.20251018034109/posts/sample-post/attributes.yml +2 -0
  72. data/test/tmp/migration_test_1760758920.backup.20251018034200/migration_test_1760758920/posts/sample-post/assets/body.md +5 -0
  73. data/test/tmp/migration_test_1760758920.backup.20251018034200/migration_test_1760758920/posts/sample-post/assets/image.jpg +2 -0
  74. data/test/tmp/migration_test_1760758920.backup.20251018034200/migration_test_1760758920/posts/sample-post/attributes.yml +2 -0
  75. data/test/tmp/migration_test_1760758920.backup.20251018034200/posts/sample-post/assets/body.md +5 -0
  76. data/test/tmp/migration_test_1760758920.backup.20251018034200/posts/sample-post/assets/image.jpg +2 -0
  77. data/test/tmp/migration_test_1760758920.backup.20251018034200/posts/sample-post/attributes.yml +2 -0
  78. data/test/tmp/migration_test_1760824728.backup.20251018215848/migration_test_1760824728/posts/assets-only/assets/test.txt +1 -0
  79. data/test/tmp/migration_test_1760824728.backup.20251018215848/migration_test_1760824728/posts/sample-post/assets/body.md +5 -0
  80. data/test/tmp/migration_test_1760824728.backup.20251018215848/migration_test_1760824728/posts/sample-post/assets/image.jpg +2 -0
  81. data/test/tmp/migration_test_1760824728.backup.20251018215848/migration_test_1760824728/posts/sample-post/attributes.yml +2 -0
  82. data/test/tmp/migration_test_1760824728.backup.20251018215848/posts/assets-only/assets/test.txt +1 -0
  83. data/test/tmp/migration_test_1760824728.backup.20251018215848/posts/sample-post/assets/body.md +5 -0
  84. data/test/tmp/migration_test_1760824728.backup.20251018215848/posts/sample-post/assets/image.jpg +2 -0
  85. data/test/tmp/migration_test_1760824728.backup.20251018215848/posts/sample-post/attributes.yml +2 -0
  86. data/test/tmp/migration_test_1760844153.backup.20251019032233/migration_test_1760844153/posts/assets-only/assets/test.txt +1 -0
  87. data/test/tmp/migration_test_1760844153.backup.20251019032233/migration_test_1760844153/posts/sample-post/assets/body.md +5 -0
  88. data/test/tmp/migration_test_1760844153.backup.20251019032233/migration_test_1760844153/posts/sample-post/assets/image.jpg +2 -0
  89. data/test/tmp/migration_test_1760844153.backup.20251019032233/migration_test_1760844153/posts/sample-post/attributes.yml +2 -0
  90. data/test/tmp/migration_test_1760844153.backup.20251019032233/posts/assets-only/assets/test.txt +1 -0
  91. data/test/tmp/migration_test_1760844153.backup.20251019032233/posts/sample-post/assets/body.md +5 -0
  92. data/test/tmp/migration_test_1760844153.backup.20251019032233/posts/sample-post/assets/image.jpg +2 -0
  93. data/test/tmp/migration_test_1760844153.backup.20251019032233/posts/sample-post/attributes.yml +2 -0
  94. data/test/tmp/new_structure_test_1760746452/mixed/test-json.json +5 -0
  95. data/test/tmp/new_structure_test_1760746452/mixed/test-yaml.yml +3 -0
  96. data/test/tmp/new_structure_test_1760746452/posts/metadata-only.yml +7 -0
  97. data/test/tmp/new_structure_test_1760746452/posts/nested-test/subdirectory/image.png +2 -0
  98. data/test/tmp/new_structure_test_1760746452/posts/nested-test.yml +7 -0
  99. data/test/tmp/new_structure_test_1760746452/posts/sample-post/body.md +5 -0
  100. data/test/tmp/new_structure_test_1760746452/posts/sample-post/image.jpg +2 -0
  101. data/test/tmp/new_structure_test_1760746452/posts/sample-post.yml +7 -0
  102. data/test/unit/asset_test.rb +90 -0
  103. data/test/unit/collection_test.rb +127 -0
  104. data/test/unit/migrator_test.rb +209 -0
  105. data/test/unit/node_test.rb +138 -0
  106. metadata +111 -18
  107. /data/public/ro/nerd/{fastest-possible-embeddings/attributes.yml → fastest-possible-embeddings.yml} +0 -0
  108. /data/public/ro/nerd/{ima/attributes.yml → ima.yml} +0 -0
  109. /data/public/ro/nerd/{index/attributes.yml → index.yml} +0 -0
  110. /data/public/ro/pages/{contact/attributes.yml → contact.yml} +0 -0
  111. /data/public/ro/pages/{cv/attributes.yml → cv.yml} +0 -0
  112. /data/public/ro/pages/{disco/attributes.yml → disco.yml} +0 -0
  113. /data/public/ro/pages/{index/attributes.yml → index.yml} +0 -0
  114. /data/public/ro/pages/{jess/attributes.yml → jess.yml} +0 -0
  115. /data/public/ro/pages/{now/attributes.yml → now.yml} +0 -0
  116. /data/public/ro/posts/{almost-died-in-an-ice-cave/attributes.yml → almost-died-in-an-ice-cave.yml} +0 -0
  117. /data/public/ro/posts/{facebook-and-global-extremism/attributes.yml → facebook-and-global-extremism.yml} +0 -0
  118. /data/public/ro/posts/{lemmings-considered-harmful/attributes.yml → lemmings-considered-harmful.yml} +0 -0
  119. /data/public/ro/posts/{lost-in-the-desert/attributes.yml → lost-in-the-desert.yml} +0 -0
  120. /data/public/ro/posts/{mission/attributes.yml → mission.yml} +0 -0
  121. /data/public/ro/posts/{return-your-laptop/attributes.yml → return-your-laptop.yml} +0 -0
@@ -0,0 +1,294 @@
1
+ # Contract: Node API
2
+
3
+ **Version**: 5.0.0 (new structure)
4
+ **Date**: 2025-10-17
5
+
6
+ ## Overview
7
+
8
+ Defines the programmatic interface for the `Ro::Node` class in the new simplified asset structure. This contract ensures that the Node API remains stable and predictable for library consumers.
9
+
10
+ ## Constructor
11
+
12
+ ### `Node.new(collection, metadata_file)`
13
+
14
+ **Purpose**: Initialize a new Node instance from a metadata file in the new structure.
15
+
16
+ **Parameters**:
17
+ - `collection` (Ro::Collection): Parent collection object
18
+ - `metadata_file` (Pathname): Path to the metadata file (e.g., `posts/my-post.yml`)
19
+
20
+ **Returns**: `Ro::Node` instance
21
+
22
+ **Raises**:
23
+ - `Errno::ENOENT`: If metadata_file does not exist
24
+ - `YAML::SyntaxError`, `JSON::ParserError`: If metadata file is malformed
25
+
26
+ **Example**:
27
+ ```ruby
28
+ collection = Ro::Collection.new(root, 'posts')
29
+ metadata_file = Pathname.new('/path/to/ro/posts/my-post.yml')
30
+ node = Ro::Node.new(collection, metadata_file)
31
+ ```
32
+
33
+ **Backward Compatibility Note**:
34
+ In the old structure (v4.x), Node was initialized with a directory path:
35
+ ```ruby
36
+ # OLD (v4.x):
37
+ node = Ro::Node.new(collection, '/path/to/ro/posts/my-post')
38
+
39
+ # NEW (v5.0):
40
+ node = Ro::Node.new(collection, '/path/to/ro/posts/my-post.yml')
41
+ ```
42
+
43
+ ---
44
+
45
+ ## Instance Methods
46
+
47
+ ### `#id` → String
48
+
49
+ **Purpose**: Returns the unique identifier for this node (derived from metadata filename).
50
+
51
+ **Returns**: String (e.g., "my-post")
52
+
53
+ **Example**:
54
+ ```ruby
55
+ node.id # => "my-post"
56
+ ```
57
+
58
+ **Derivation**:
59
+ - Old structure: Basename of node directory (e.g., `posts/my-post/` → "my-post")
60
+ - New structure: Basename of metadata file without extension (e.g., `posts/my-post.yml` → "my-post")
61
+
62
+ ---
63
+
64
+ ### `#path` → Pathname
65
+
66
+ **Purpose**: Returns the path to the node's asset directory.
67
+
68
+ **Returns**: Pathname
69
+
70
+ **Example**:
71
+ ```ruby
72
+ node.path # => #<Pathname:/path/to/ro/posts/my-post>
73
+ ```
74
+
75
+ **Behavior Change**:
76
+ - Old structure: Path to directory containing `attributes.yml` (e.g., `/posts/my-post/`)
77
+ - New structure: Path to directory containing assets (e.g., `/posts/my-post/`), derived from metadata filename
78
+
79
+ ---
80
+
81
+ ### `#metadata_file` → Pathname
82
+
83
+ **Purpose**: Returns the path to the metadata file (NEW in v5.0).
84
+
85
+ **Returns**: Pathname
86
+
87
+ **Example**:
88
+ ```ruby
89
+ node.metadata_file # => #<Pathname:/path/to/ro/posts/my-post.yml>
90
+ ```
91
+
92
+ **Note**: This is a new method in v5.0. In v4.x, metadata was always at `node.path / 'attributes.yml'`.
93
+
94
+ ---
95
+
96
+ ### `#attributes` → Hash
97
+
98
+ **Purpose**: Returns the parsed metadata as a hash.
99
+
100
+ **Returns**: Hash with symbol keys
101
+
102
+ **Example**:
103
+ ```ruby
104
+ node.attributes # => { title: "My Post", author: "John", tags: ["ruby"] }
105
+ ```
106
+
107
+ **Unchanged**: This method works the same in both old and new structures.
108
+
109
+ ---
110
+
111
+ ### `#[]` (alias: `#get`, `#fetch`) → Object
112
+
113
+ **Purpose**: Access a specific attribute by key.
114
+
115
+ **Parameters**:
116
+ - `key` (String or Symbol): Attribute name
117
+
118
+ **Returns**: Attribute value or nil
119
+
120
+ **Example**:
121
+ ```ruby
122
+ node[:title] # => "My Post"
123
+ node.get(:author) # => "John"
124
+ ```
125
+
126
+ **Unchanged**: This method works the same in both old and new structures.
127
+
128
+ ---
129
+
130
+ ### `#asset_dir` → Pathname
131
+
132
+ **Purpose**: Returns the path to the directory containing assets.
133
+
134
+ **Returns**: Pathname
135
+
136
+ **Example**:
137
+ ```ruby
138
+ node.asset_dir # => #<Pathname:/path/to/ro/posts/my-post>
139
+ ```
140
+
141
+ **Behavior Change**:
142
+ - Old structure: `node.path / 'assets'` (e.g., `/posts/my-post/assets/`)
143
+ - New structure: `node.path` (e.g., `/posts/my-post/`)
144
+
145
+ ---
146
+
147
+ ### `#asset_paths` → Array<Pathname>
148
+
149
+ **Purpose**: Returns paths to all files in the asset directory.
150
+
151
+ **Returns**: Array of Pathname objects (sorted)
152
+
153
+ **Example**:
154
+ ```ruby
155
+ node.asset_paths # => [#<Pathname:.../cover.jpg>, #<Pathname:.../diagram.png>]
156
+ ```
157
+
158
+ **Behavior Change**:
159
+ - Old structure: Files from `node.path / 'assets'`
160
+ - New structure: Files from `node.path` (excluding ignored files)
161
+
162
+ ---
163
+
164
+ ### `#assets` → Array<Ro::Asset>
165
+
166
+ **Purpose**: Returns Asset objects for all files in the asset directory.
167
+
168
+ **Returns**: Array of `Ro::Asset` instances
169
+
170
+ **Example**:
171
+ ```ruby
172
+ node.assets # => [#<Ro::Asset path=.../cover.jpg>, #<Ro::Asset path=.../diagram.png>]
173
+ ```
174
+
175
+ **Unchanged**: Returns Asset instances regardless of structure.
176
+
177
+ ---
178
+
179
+ ### `#update_attributes!(attrs, file: nil)` → void
180
+
181
+ **Purpose**: Updates node metadata and saves to disk.
182
+
183
+ **Parameters**:
184
+ - `attrs` (Hash): New attribute values
185
+ - `file` (Pathname, optional): Specific file to save to (defaults to metadata_file)
186
+
187
+ **Returns**: void
188
+
189
+ **Raises**:
190
+ - `Errno::EACCES`: If metadata file is not writable
191
+
192
+ **Example**:
193
+ ```ruby
194
+ node.update_attributes!(title: "Updated Title", author: "Jane")
195
+ ```
196
+
197
+ **Behavior Change**:
198
+ - Old structure: Saves to `node.path / 'attributes.yml'`
199
+ - New structure: Saves to `node.metadata_file` (e.g., `posts/my-post.yml`)
200
+
201
+ ---
202
+
203
+ ## Test Requirements
204
+
205
+ ### Unit Tests
206
+
207
+ Must verify for the NEW structure:
208
+
209
+ 1. **Initialization**:
210
+ - ✓ Creates node from metadata file
211
+ - ✓ Derives ID from filename (without extension)
212
+ - ✓ Raises error if metadata file doesn't exist
213
+ - ✓ Raises error if metadata file is malformed
214
+
215
+ 2. **Attribute Access**:
216
+ - ✓ Loads attributes from metadata file
217
+ - ✓ Returns correct values for `#[]`, `#get`, `#fetch`
218
+ - ✓ Returns Hash for `#attributes`
219
+
220
+ 3. **Asset Management**:
221
+ - ✓ `#asset_dir` returns correct path (node directory, not assets/ subdirectory)
222
+ - ✓ `#asset_paths` returns files from node directory
223
+ - ✓ `#assets` returns Asset instances
224
+ - ✓ Ignores metadata file itself (don't treat as asset)
225
+
226
+ 4. **Metadata Updates**:
227
+ - ✓ `#update_attributes!` saves to correct metadata file
228
+ - ✓ Preserves existing attributes when updating subset
229
+ - ✓ Handles file write errors gracefully
230
+
231
+ ### Integration Tests
232
+
233
+ Must verify interaction between Node, Collection, and Asset:
234
+
235
+ 1. **Node Discovery**:
236
+ - ✓ Collection finds nodes by detecting metadata files
237
+ - ✓ Node ID matches metadata filename (without extension)
238
+ - ✓ Node path corresponds to asset directory
239
+
240
+ 2. **Asset Resolution**:
241
+ - ✓ Assets resolve to correct URLs
242
+ - ✓ Nested assets (subdirectories) work correctly
243
+ - ✓ Asset paths are relative to node directory (no `assets/` prefix)
244
+
245
+ 3. **Metadata Formats**:
246
+ - ✓ Supports YAML (`.yml`, `.yaml`)
247
+ - ✓ Supports JSON (`.json`)
248
+ - ✓ Handles missing optional asset directory
249
+
250
+ ---
251
+
252
+ ## Migration Compatibility
253
+
254
+ ### Transition Period
255
+
256
+ During migration from v4.x to v5.0, the Node class must:
257
+
258
+ 1. **Detect old structure**: If initialized with a directory path (old API), detect old structure
259
+ 2. **Prefer old structure**: Per FR-011, if both structures exist, use old structure
260
+ 3. **Fail gracefully**: If neither structure exists, raise clear error
261
+
262
+ **Pseudo-code for compatibility**:
263
+ ```ruby
264
+ def initialize(collection, path_or_metadata)
265
+ if path_or_metadata.directory?
266
+ # Old structure (v4.x compatibility)
267
+ @path = path_or_metadata
268
+ @metadata_file = @path / 'attributes.yml'
269
+ elsif path_or_metadata.file?
270
+ # New structure (v5.0)
271
+ @metadata_file = path_or_metadata
272
+ @path = derive_asset_directory_from_metadata_file
273
+ else
274
+ raise "Invalid node: #{path_or_metadata}"
275
+ end
276
+ end
277
+ ```
278
+
279
+ **NOTE**: This compatibility code is ONLY for migration period. In final v5.0 release, only the new signature should be supported.
280
+
281
+ ---
282
+
283
+ ## Breaking Changes from v4.x
284
+
285
+ | Method | v4.x Behavior | v5.0 Behavior | Breaking? |
286
+ |--------|---------------|---------------|-----------|
287
+ | `Node.new` | Accepts directory path | Accepts metadata file path | YES |
288
+ | `#path` | Returns node directory | Returns asset directory (same location) | NO |
289
+ | `#asset_dir` | Returns `path/assets/` | Returns `path` | YES |
290
+ | `#metadata_file` | N/A (implicit) | Returns explicit metadata file path | NEW |
291
+ | `#id` | From directory name | From metadata filename | NO (same value) |
292
+ | `#attributes` | Loaded from `path/attributes.yml` | Loaded from `metadata_file` | NO (same data) |
293
+
294
+ **Migration Impact**: Code that calls `Node.new` directly must be updated. Code that uses Node instances should mostly work unchanged, except for `#asset_dir` which now points to the node directory instead of `assets/` subdirectory.
@@ -0,0 +1,381 @@
1
+ # Data Model: Simplify Asset Directory Structure
2
+
3
+ **Date**: 2025-10-17
4
+ **Feature**: 001-simplify-asset-structure
5
+
6
+ ## Overview
7
+
8
+ This document defines the data entities and their relationships for the simplified asset directory structure. The ro gem uses a file-based data model where entities are represented by directories and files on the filesystem.
9
+
10
+ ## Core Entities
11
+
12
+ ### Entity: Root
13
+
14
+ **Description**: Top-level entry point representing the ro directory structure. Contains one or more Collections.
15
+
16
+ **Attributes**:
17
+ - `path` (Pathname): Absolute path to the root directory
18
+ - `collections` (Array<Collection>): Child collections discovered by scanning subdirectories
19
+
20
+ **Relationships**:
21
+ - **Has many** Collections (1:N)
22
+
23
+ **File System Representation**:
24
+ ```
25
+ /path/to/ro/
26
+ ├── posts/ # Collection
27
+ ├── pages/ # Collection
28
+ └── nerd/ # Collection
29
+ ```
30
+
31
+ **Validation Rules**:
32
+ - Path must exist and be a directory
33
+ - Path must be readable
34
+
35
+ **State Transitions**: N/A (immutable once initialized)
36
+
37
+ ---
38
+
39
+ ### Entity: Collection
40
+
41
+ **Description**: A named category of related Assets (e.g., "posts", "pages"). Contains multiple Nodes. In the new structure, Collections are discovered as subdirectories of Root that contain metadata files.
42
+
43
+ **Attributes**:
44
+ - `name` (String): Collection identifier (derived from directory name)
45
+ - `path` (Pathname): Absolute path to the collection directory
46
+ - `root` (Root): Parent root instance
47
+ - `nodes` (Array<Node>): Child nodes discovered by scanning for metadata files
48
+
49
+ **Relationships**:
50
+ - **Belongs to** Root (N:1)
51
+ - **Has many** Nodes (1:N)
52
+
53
+ **File System Representation (New Structure)**:
54
+ ```
55
+ /path/to/ro/posts/
56
+ ├── my-first-post.yml # Node metadata
57
+ ├── my-first-post/ # Node asset directory
58
+ │ ├── body.md
59
+ │ └── cover.jpg
60
+ ├── another-post.yml # Node metadata
61
+ └── another-post/ # Node asset directory
62
+ └── content.md
63
+ ```
64
+
65
+ **File System Representation (Old Structure)**:
66
+ ```
67
+ /path/to/ro/posts/
68
+ ├── my-first-post/ # Node directory
69
+ │ ├── attributes.yml # Metadata (inside node dir)
70
+ │ ├── body.md
71
+ │ └── assets/ # Asset subdirectory
72
+ │ └── cover.jpg
73
+ └── another-post/ # Node directory
74
+ ├── attributes.yml
75
+ └── assets/
76
+ └── content.md
77
+ ```
78
+
79
+ **Validation Rules**:
80
+ - Name must be a valid directory name
81
+ - Path must exist and be a directory
82
+ - Must contain at least one metadata file (new structure) or subdirectory (old structure)
83
+
84
+ **Discovery Logic Changes**:
85
+ - **Old**: Iterate subdirectories, each is a potential Node
86
+ - **New**: Scan for `*.{yml,yaml,json,toml}` files, derive Node from each metadata file
87
+
88
+ **State Transitions**: N/A (immutable once initialized)
89
+
90
+ ---
91
+
92
+ ### Entity: Node
93
+
94
+ **Description**: A single content item with metadata and optional associated files. Represents one logical asset (e.g., a blog post, page, or article).
95
+
96
+ **Attributes**:
97
+ - `id` (String): Node identifier (derived from metadata filename in new structure, directory name in old)
98
+ - `path` (Pathname): Path to node location (directory in old structure, collection path in new)
99
+ - `metadata_file` (Pathname): Path to metadata file (NEW: separate from path)
100
+ - `attributes` (Hash): Parsed metadata content (YAML/JSON/TOML)
101
+ - `collection` (Collection): Parent collection
102
+ - `assets` (Array<Asset>): Associated files (images, documents, etc.)
103
+ - `content_files` (Array<Pathname>): Non-asset files (e.g., body.md)
104
+
105
+ **Relationships**:
106
+ - **Belongs to** Collection (N:1)
107
+ - **Has many** Assets (1:N)
108
+
109
+ **File System Representation (New Structure)**:
110
+ ```
111
+ Node ID: "my-post"
112
+
113
+ /path/to/ro/posts/my-post.yml # Metadata file (at collection level)
114
+ /path/to/ro/posts/my-post/ # Asset directory (sibling to metadata)
115
+ ├── body.md # Content file
116
+ ├── image1.png # Asset
117
+ └── subdir/ # Nested directory
118
+ └── image2.jpg # Nested asset
119
+ ```
120
+
121
+ **File System Representation (Old Structure)**:
122
+ ```
123
+ Node ID: "my-post"
124
+
125
+ /path/to/ro/posts/my-post/ # Node directory
126
+ ├── attributes.yml # Metadata (inside node dir)
127
+ ├── body.md # Content file (same level as attributes)
128
+ └── assets/ # Asset subdirectory
129
+ ├── image1.png # Asset
130
+ └── subdir/ # Nested directory
131
+ └── image2.jpg # Nested asset
132
+ ```
133
+
134
+ **Validation Rules**:
135
+ - ID must be a valid filename (for metadata file)
136
+ - Metadata file must exist and contain valid YAML/JSON/TOML
137
+ - Asset directory is optional (metadata-only nodes are valid per FR-007)
138
+ - Metadata file is optional (files-only nodes may be supported per FR-008, but edge case)
139
+
140
+ **State Transitions**:
141
+ 1. **Unloaded** → Load metadata → **Loaded**
142
+ 2. **Loaded** → Update attributes → **Modified** → Save → **Loaded**
143
+ 3. **Loaded** → Add asset → **Modified** → Save → **Loaded**
144
+
145
+ **Key Behavioral Changes**:
146
+ - **Old**: `Node.new(node_directory_path)` - path IS the node
147
+ - **New**: `Node.new(collection_path, metadata_file_path)` - separate metadata from assets
148
+ - **Old**: `node.asset_dir` returns `node_path/assets/`
149
+ - **New**: `node.asset_dir` returns `collection_path/node_id/`
150
+ - **Old**: `_load_base_attributes` searches for `./attributes.{yml,yaml,json}`
151
+ - **New**: `_load_base_attributes` loads from explicit `@metadata_file` path
152
+
153
+ ---
154
+
155
+ ### Entity: Asset
156
+
157
+ **Description**: A file associated with a Node (image, document, video, etc.). Assets are files within the node's asset directory.
158
+
159
+ **Attributes**:
160
+ - `path` (Pathname): Absolute path to the asset file
161
+ - `relative_path` (Pathname): Path relative to asset directory
162
+ - `node` (Node): Parent node
163
+ - `url` (String): Generated URL for accessing the asset
164
+
165
+ **Relationships**:
166
+ - **Belongs to** Node (N:1)
167
+
168
+ **File System Representation (New Structure)**:
169
+ ```
170
+ Asset: /path/to/ro/posts/my-post/images/cover.jpg
171
+
172
+ Node: my-post
173
+ Relative path: images/cover.jpg
174
+ URL: /posts/my-post/images/cover.jpg
175
+ ```
176
+
177
+ **File System Representation (Old Structure)**:
178
+ ```
179
+ Asset: /path/to/ro/posts/my-post/assets/images/cover.jpg
180
+
181
+ Node: my-post
182
+ Relative path: images/cover.jpg (assets/ prefix stripped)
183
+ URL: /posts/my-post/images/cover.jpg
184
+ ```
185
+
186
+ **Validation Rules**:
187
+ - Path must exist and be a file (not directory)
188
+ - Must be within node's asset directory
189
+ - URL must be generated correctly regardless of structure
190
+
191
+ **Key Behavioral Changes**:
192
+ - **Old**: Asset paths include `/assets/` segment that must be stripped for URLs
193
+ - **New**: Asset paths are already relative to node, no stripping needed
194
+ - **Old**: `Asset` splits path on `/assets/` to find node (lib/ro/asset.rb:12)
195
+ - **New**: `Asset` splits path on node ID to find node and relative path
196
+
197
+ ---
198
+
199
+ ## Entity Relationships Diagram
200
+
201
+ ```
202
+ Root (1)
203
+
204
+ └─ has many ─→ Collection (N)
205
+
206
+ └─ has many ─→ Node (N)
207
+
208
+ └─ has many ─→ Asset (N)
209
+ ```
210
+
211
+ **Cardinality**:
212
+ - 1 Root : N Collections
213
+ - 1 Collection : N Nodes
214
+ - 1 Node : N Assets (0..N, assets are optional)
215
+
216
+ ## Migration State Model
217
+
218
+ ### Entity: MigrationState
219
+
220
+ **Description**: Tracks the migration status of a single node from old structure to new structure. Used by the migration tool to ensure safe, resumable migrations.
221
+
222
+ **Attributes**:
223
+ - `node_id` (String): Identifier of the node being migrated
224
+ - `collection_name` (String): Name of parent collection
225
+ - `source_structure` (Symbol): `:old` or `:new`
226
+ - `status` (Symbol): `:pending`, `:in_progress`, `:completed`, `:failed`, `:rolled_back`
227
+ - `metadata_migrated` (Boolean): Whether metadata file has been moved
228
+ - `assets_migrated` (Boolean): Whether asset files have been moved
229
+ - `backup_path` (Pathname): Location of backup (if created)
230
+ - `error` (String): Error message if status is `:failed`
231
+
232
+ **State Transitions**:
233
+ ```
234
+ :pending
235
+
236
+ :in_progress (metadata copied)
237
+
238
+ :in_progress (assets copied)
239
+
240
+ :completed (old structure removed)
241
+
242
+ OR
243
+
244
+ :in_progress
245
+
246
+ :failed (error occurred)
247
+
248
+ :rolled_back (restored from backup)
249
+ ```
250
+
251
+ **Validation Rules**:
252
+ - Cannot transition to `:completed` unless both `metadata_migrated` and `assets_migrated` are true
253
+ - Backup must exist before attempting rollback
254
+ - Cannot re-migrate a node in `:completed` state (skip if already migrated)
255
+
256
+ ---
257
+
258
+ ## Data Constraints
259
+
260
+ ### Uniqueness Constraints
261
+
262
+ 1. **Node ID within Collection**: Each node ID must be unique within its collection
263
+ - **Old structure**: Directory names are unique by filesystem constraint
264
+ - **New structure**: Metadata filenames (without extension) must be unique
265
+ - **Validation**: Migration tool must detect duplicate IDs before migrating
266
+
267
+ 2. **Collection Name within Root**: Each collection name must be unique within root
268
+ - Enforced by filesystem (directory names are unique)
269
+
270
+ ### Referential Integrity
271
+
272
+ 1. **Asset → Node**: Every asset must belong to a valid node
273
+ - **Old structure**: Assets in `node_dir/assets/` are children of `node_dir`
274
+ - **New structure**: Assets in `node_id/` are children of node with `node_id.yml`
275
+ - **Validation**: Orphaned asset directories (no corresponding metadata file) should be flagged
276
+
277
+ 2. **Node → Collection**: Every node must belong to a valid collection
278
+ - Enforced by file system structure (nodes are discovered from collection directories)
279
+
280
+ ### Format Constraints
281
+
282
+ 1. **Metadata File Extensions**: Only `.yml`, `.yaml`, `.json`, `.toml` are recognized
283
+ 2. **Metadata Content**: Must be valid YAML/JSON/TOML, parse errors should fail gracefully
284
+ 3. **Path Characters**: Must be valid filesystem paths (platform-specific constraints)
285
+
286
+ ---
287
+
288
+ ## Data Format Examples
289
+
290
+ ### Metadata File Content (Unchanged Between Structures)
291
+
292
+ **YAML Format** (`my-post.yml`):
293
+ ```yaml
294
+ title: "My First Post"
295
+ author: "John Doe"
296
+ published_at: 2025-01-15
297
+ tags:
298
+ - ruby
299
+ - programming
300
+ featured_image: cover.jpg
301
+ ```
302
+
303
+ **JSON Format** (`my-post.json`):
304
+ ```json
305
+ {
306
+ "title": "My First Post",
307
+ "author": "John Doe",
308
+ "published_at": "2025-01-15",
309
+ "tags": ["ruby", "programming"],
310
+ "featured_image": "cover.jpg"
311
+ }
312
+ ```
313
+
314
+ ### File Structure Comparison
315
+
316
+ **Old Structure**:
317
+ ```
318
+ posts/
319
+ └── my-post/
320
+ ├── attributes.yml # Metadata
321
+ ├── body.md # Content
322
+ └── assets/ # Assets subdirectory
323
+ ├── cover.jpg
324
+ └── diagram.png
325
+ ```
326
+
327
+ **New Structure**:
328
+ ```
329
+ posts/
330
+ ├── my-post.yml # Metadata (moved up one level)
331
+ └── my-post/ # Asset directory (no more assets/ nesting)
332
+ ├── body.md # Content
333
+ ├── cover.jpg
334
+ └── diagram.png
335
+ ```
336
+
337
+ **Key Differences**:
338
+ 1. Metadata file moved from `posts/my-post/attributes.yml` → `posts/my-post.yml`
339
+ 2. Assets moved from `posts/my-post/assets/` → `posts/my-post/`
340
+ 3. Content files (`body.md`) moved from `posts/my-post/body.md` → `posts/my-post/body.md` (same relative location, but different absolute due to restructure)
341
+
342
+ ---
343
+
344
+ ## Performance Considerations
345
+
346
+ ### Lookup Performance
347
+
348
+ **Old Structure**:
349
+ - Collection discovery: O(N) where N = subdirectories in root
350
+ - Node discovery: O(M) where M = subdirectories in collection
351
+ - Metadata loading: 1 file read per node (`attributes.yml`)
352
+
353
+ **New Structure**:
354
+ - Collection discovery: O(N) where N = subdirectories in root (unchanged)
355
+ - Node discovery: O(M) where M = metadata files in collection (file glob instead of directory iteration)
356
+ - Metadata loading: 1 file read per node (unchanged)
357
+
358
+ **Analysis**: New structure should have similar or slightly better performance because:
359
+ 1. File glob for `*.yml` is typically faster than directory stat for subdirectories
360
+ 2. Fewer levels of nesting = fewer filesystem operations
361
+ 3. No need to look inside directories to find `attributes.yml`
362
+
363
+ **Success Criteria**: Must maintain <100ms asset lookup for 10,000 assets (SC-001)
364
+
365
+ ### Memory Footprint
366
+
367
+ - **Old structure**: Minimal - only loaded node metadata is kept in memory
368
+ - **New structure**: Same - no change to memory model
369
+ - **Migration tool**: May need to load multiple nodes simultaneously, estimate ~1MB per 100 nodes
370
+
371
+ ---
372
+
373
+ ## Index Requirements
374
+
375
+ No database indexes are required (file-based system). However, for optimal performance:
376
+
377
+ 1. **Collection-level caching**: Consider memoizing the list of metadata files in a collection
378
+ 2. **Node-level caching**: Consider memoizing parsed attributes to avoid re-parsing on each access
379
+ 3. **Asset-level caching**: File stats could be cached if performance becomes an issue
380
+
381
+ **Note**: Current implementation does NOT use caching. Add only if performance benchmarks indicate need.