docsmith 0.1.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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rspec_status +212 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +66 -0
- data/Rakefile +8 -0
- data/USAGE.md +510 -0
- data/docs/superpowers/plans/2026-04-01-docsmith-full-plan.md +6459 -0
- data/docs/superpowers/plans/2026-04-08-parsers-remove-branches-docs.md +2112 -0
- data/docs/superpowers/specs/2026-04-01-docsmith-phase1-design.md +340 -0
- data/docsmith_spec.md +630 -0
- data/lib/docsmith/auto_save.rb +29 -0
- data/lib/docsmith/comments/anchor.rb +68 -0
- data/lib/docsmith/comments/comment.rb +44 -0
- data/lib/docsmith/comments/manager.rb +73 -0
- data/lib/docsmith/comments/migrator.rb +64 -0
- data/lib/docsmith/configuration.rb +95 -0
- data/lib/docsmith/diff/engine.rb +39 -0
- data/lib/docsmith/diff/parsers/html.rb +64 -0
- data/lib/docsmith/diff/parsers/markdown.rb +60 -0
- data/lib/docsmith/diff/renderers/base.rb +62 -0
- data/lib/docsmith/diff/renderers/registry.rb +41 -0
- data/lib/docsmith/diff/renderers.rb +10 -0
- data/lib/docsmith/diff/result.rb +77 -0
- data/lib/docsmith/diff.rb +6 -0
- data/lib/docsmith/document.rb +44 -0
- data/lib/docsmith/document_version.rb +50 -0
- data/lib/docsmith/errors.rb +18 -0
- data/lib/docsmith/events/event.rb +19 -0
- data/lib/docsmith/events/hook_registry.rb +14 -0
- data/lib/docsmith/events/notifier.rb +22 -0
- data/lib/docsmith/rendering/html_renderer.rb +36 -0
- data/lib/docsmith/rendering/json_renderer.rb +29 -0
- data/lib/docsmith/version.rb +5 -0
- data/lib/docsmith/version_manager.rb +143 -0
- data/lib/docsmith/version_tag.rb +25 -0
- data/lib/docsmith/versionable.rb +252 -0
- data/lib/docsmith.rb +52 -0
- data/lib/generators/docsmith/install/install_generator.rb +27 -0
- data/lib/generators/docsmith/install/templates/create_docsmith_tables.rb.erb +64 -0
- data/lib/generators/docsmith/install/templates/docsmith_initializer.rb.erb +19 -0
- data/sig/docsmith.rbs +4 -0
- metadata +196 -0
data/docsmith_spec.md
ADDED
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
# Docsmith — Ruby Gem Specification
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
Docsmith is a plug-and-play document version manager gem for Rails. It provides full snapshot-based versioning, multi-format diff rendering, inline commenting, and branch/merge — all via ActiveRecord.
|
|
5
|
+
|
|
6
|
+
**Gem name:** `docsmith`
|
|
7
|
+
**Module namespace:** `Docsmith`
|
|
8
|
+
**Ruby:** >= 3.1
|
|
9
|
+
**Rails:** >= 7.0
|
|
10
|
+
**License:** MIT
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Design Decisions
|
|
15
|
+
|
|
16
|
+
**Why full snapshots over deltas?** Three content types (HTML, Markdown, JSON)
|
|
17
|
+
would need three different delta formats. Full snapshots keep storage simple,
|
|
18
|
+
make rollback trivial (just copy), and let us swap diff algorithms per content
|
|
19
|
+
type without migrating data. Storage cost is negligible for text documents.
|
|
20
|
+
|
|
21
|
+
**Why diff-lcs over Diffy?** Diffy wraps Unix `diff` binary — adds a system
|
|
22
|
+
dependency for ~70% coverage across our content types. diff-lcs is pure Ruby,
|
|
23
|
+
already a transitive dependency via RSpec, and gives us full programmatic
|
|
24
|
+
control over change objects for stats, comment anchoring, and merge detection.
|
|
25
|
+
|
|
26
|
+
**Why not PaperTrail/Logidze/Audited?** Those are model-level auditing gems.
|
|
27
|
+
They track attribute changes on any AR model. Docsmith treats documents as
|
|
28
|
+
first-class citizens with content-type-aware diffing, commenting, branching,
|
|
29
|
+
and multi-format rendering. Different problem space.
|
|
30
|
+
|
|
31
|
+
**Config precedence:** per-class `docsmith_config` > global `Docsmith.configure` > gem defaults.
|
|
32
|
+
Any setting not specified at a level falls through to the next level.
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## Architecture Summary
|
|
37
|
+
|
|
38
|
+
| Concern | Approach |
|
|
39
|
+
|---|---|
|
|
40
|
+
| Storage | ActiveRecord (PostgreSQL/MySQL/SQLite) |
|
|
41
|
+
| Document types | Rich text/HTML, Markdown, Structured JSON |
|
|
42
|
+
| Versioning | Full snapshots, diff computed on read |
|
|
43
|
+
| Version creation | Auto-save with configurable debounce |
|
|
44
|
+
| Diffing | diff-lcs (pure Ruby). Snapshots stored, diffs computed on read. Custom renderers per content type can be added later — start with line-level for all types |
|
|
45
|
+
| Comments | Document-level + range-anchored inline annotations |
|
|
46
|
+
| User identity | Polymorphic association (`author_type` / `author_id`) |
|
|
47
|
+
| Integration | Mixin (`include Docsmith::Versionable`) + Service objects |
|
|
48
|
+
| DB setup | `rails generate docsmith:install` |
|
|
49
|
+
| Events | Callback hooks + ActiveSupport::Notifications |
|
|
50
|
+
| Output formats | HTML and JSON |
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## Phase 1 — Core Versioning (snapshots, restore, tags)
|
|
55
|
+
|
|
56
|
+
### Database Tables
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
docsmith_documents
|
|
60
|
+
id :bigint, PK
|
|
61
|
+
title :string
|
|
62
|
+
content_type :string # "html", "markdown", "json"
|
|
63
|
+
current_version :integer, default: 0
|
|
64
|
+
metadata :jsonb, default: {}
|
|
65
|
+
created_at :datetime
|
|
66
|
+
updated_at :datetime
|
|
67
|
+
|
|
68
|
+
docsmith_versions
|
|
69
|
+
id :bigint, PK
|
|
70
|
+
document_id :bigint, FK -> docsmith_documents
|
|
71
|
+
version_number :integer
|
|
72
|
+
content :text # full snapshot
|
|
73
|
+
content_type :string # inherited from document at save time
|
|
74
|
+
author_type :string # polymorphic
|
|
75
|
+
author_id :bigint # polymorphic
|
|
76
|
+
change_summary :string, nullable
|
|
77
|
+
metadata :jsonb, default: {}
|
|
78
|
+
created_at :datetime
|
|
79
|
+
|
|
80
|
+
index: [document_id, version_number], unique: true
|
|
81
|
+
index: [author_type, author_id]
|
|
82
|
+
|
|
83
|
+
docsmith_version_tags
|
|
84
|
+
id :bigint, PK
|
|
85
|
+
version_id :bigint, FK -> docsmith_versions
|
|
86
|
+
name :string # e.g. "v2.1-final"
|
|
87
|
+
author_type :string
|
|
88
|
+
author_id :bigint
|
|
89
|
+
created_at :datetime
|
|
90
|
+
|
|
91
|
+
index: [version_id, name], unique: true
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### Mixin API (Phase 1)
|
|
95
|
+
|
|
96
|
+
```ruby
|
|
97
|
+
class Article < ApplicationRecord
|
|
98
|
+
include Docsmith::Versionable
|
|
99
|
+
|
|
100
|
+
# Per-class config (all optional — falls through to global config if not set)
|
|
101
|
+
# Per-class ALWAYS takes precedence over global config
|
|
102
|
+
docsmith_config do
|
|
103
|
+
content_field :body # field to version
|
|
104
|
+
content_type :html # overrides global default (:markdown)
|
|
105
|
+
auto_save true
|
|
106
|
+
debounce 60.seconds # overrides global default (30.seconds)
|
|
107
|
+
max_versions nil # nil = unlimited
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Resolution order: per-class config > global config > gem defaults
|
|
112
|
+
# If Article sets content_type: :html but not debounce,
|
|
113
|
+
# it uses :html for content_type and global config value for debounce
|
|
114
|
+
|
|
115
|
+
# Usage
|
|
116
|
+
article = Article.find(1)
|
|
117
|
+
|
|
118
|
+
# Saving versions
|
|
119
|
+
article.save_version!(author: current_user, summary: "Fixed intro")
|
|
120
|
+
article.save_version!(author: current_user) # summary optional
|
|
121
|
+
|
|
122
|
+
# Auto-save (respects debounce)
|
|
123
|
+
article.auto_save_version!(author: current_user)
|
|
124
|
+
# Returns false if debounce period hasn't elapsed
|
|
125
|
+
|
|
126
|
+
# Reading versions
|
|
127
|
+
article.versions # => ActiveRecord relation of Docsmith::Version
|
|
128
|
+
article.versions.count # => 5
|
|
129
|
+
article.current_version # => Docsmith::Version
|
|
130
|
+
article.version(3) # => Docsmith::Version for v3
|
|
131
|
+
|
|
132
|
+
# Restoring
|
|
133
|
+
article.restore_version!(3, author: current_user)
|
|
134
|
+
# Creates NEW version (v6) with content from v3. Never mutates history.
|
|
135
|
+
|
|
136
|
+
# Tagging
|
|
137
|
+
article.tag_version!(3, name: "v1.0-release", author: current_user)
|
|
138
|
+
article.tagged_version("v1.0-release") # => Docsmith::Version
|
|
139
|
+
article.version_tags(3) # => ["v1.0-release"]
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### Service Object API (Phase 1)
|
|
143
|
+
|
|
144
|
+
```ruby
|
|
145
|
+
doc = Docsmith::Document.create!(
|
|
146
|
+
title: "API Spec",
|
|
147
|
+
content: "# Hello",
|
|
148
|
+
content_type: :markdown
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
# Or wrap an existing record
|
|
152
|
+
doc = Docsmith::Document.from_record(article, field: :body)
|
|
153
|
+
|
|
154
|
+
Docsmith::VersionManager.save!(doc, author: current_user, summary: "Initial")
|
|
155
|
+
Docsmith::VersionManager.restore!(doc, version: 3, author: current_user)
|
|
156
|
+
Docsmith::VersionManager.tag!(doc, version: 3, name: "v1.0", author: current_user)
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### Events (Phase 1)
|
|
160
|
+
|
|
161
|
+
```ruby
|
|
162
|
+
# Callback hooks
|
|
163
|
+
Docsmith.configure do |config|
|
|
164
|
+
config.on(:version_created) do |event|
|
|
165
|
+
# event.document, event.version, event.author
|
|
166
|
+
end
|
|
167
|
+
config.on(:version_restored) do |event|
|
|
168
|
+
# event.document, event.from_version, event.to_version, event.author
|
|
169
|
+
end
|
|
170
|
+
config.on(:version_tagged) do |event|
|
|
171
|
+
# event.version, event.tag_name, event.author
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# ActiveSupport::Notifications (always emitted)
|
|
176
|
+
ActiveSupport::Notifications.subscribe("version_created.docsmith") do |event|
|
|
177
|
+
# event.payload[:document], [:version], [:author]
|
|
178
|
+
end
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### Generator (Phase 1)
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
rails generate docsmith:install
|
|
185
|
+
# Creates:
|
|
186
|
+
# db/migrate/TIMESTAMP_create_docsmith_tables.rb
|
|
187
|
+
# config/initializers/docsmith.rb
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
---
|
|
191
|
+
|
|
192
|
+
## Phase 2 — HTML/JSON Rendering & Diff Views
|
|
193
|
+
|
|
194
|
+
### Diff Engine
|
|
195
|
+
|
|
196
|
+
Uses `diff-lcs` (pure Ruby, zero system dependencies) for all diffing.
|
|
197
|
+
All content types use line-level diffing for v1. Content-type-specific
|
|
198
|
+
renderers (DOM-aware for HTML, key-path for JSON) can be added later
|
|
199
|
+
without changing storage — that's the benefit of full snapshots.
|
|
200
|
+
|
|
201
|
+
```ruby
|
|
202
|
+
# Compute diff between any two versions
|
|
203
|
+
diff = Docsmith::Diff.between(version_a, version_b)
|
|
204
|
+
|
|
205
|
+
diff.content_type # => "markdown"
|
|
206
|
+
diff.additions # => integer count
|
|
207
|
+
diff.deletions # => integer count
|
|
208
|
+
diff.changes # => array of change objects
|
|
209
|
+
|
|
210
|
+
# Render diff
|
|
211
|
+
diff.to_html # => HTML string with inline highlighting
|
|
212
|
+
diff.to_json # => structured JSON diff
|
|
213
|
+
|
|
214
|
+
# Compare with current
|
|
215
|
+
diff = article.diff_from(3) # version 3 vs current
|
|
216
|
+
diff = article.diff_between(2, 5) # version 2 vs version 5
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
### Content-Type Diff Renderers
|
|
220
|
+
|
|
221
|
+
v1: All content types use the same line-level diff renderer via diff-lcs.
|
|
222
|
+
For JSON, content is pretty-printed before diffing so key changes show
|
|
223
|
+
as line changes. Custom renderers can be registered later.
|
|
224
|
+
|
|
225
|
+
```ruby
|
|
226
|
+
# v1: single renderer handles all types (line-level via diff-lcs)
|
|
227
|
+
Docsmith::DiffRenderer::Base # line-level, works for all types
|
|
228
|
+
|
|
229
|
+
# Future: content-type-specific renderers (register when needed)
|
|
230
|
+
# Docsmith::DiffRenderer::Html # DOM-aware, tag-level
|
|
231
|
+
# Docsmith::DiffRenderer::Json # key-path aware, value comparison
|
|
232
|
+
|
|
233
|
+
# Custom renderer registration (available now, use when ready)
|
|
234
|
+
Docsmith.configure do |config|
|
|
235
|
+
config.register_diff_renderer(:custom_type, MyCustomRenderer)
|
|
236
|
+
end
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
### Document Rendering
|
|
240
|
+
|
|
241
|
+
```ruby
|
|
242
|
+
version = article.version(3)
|
|
243
|
+
|
|
244
|
+
# Render document content (not diff) in output format
|
|
245
|
+
version.render(:html) # => HTML representation
|
|
246
|
+
version.render(:json) # => JSON representation
|
|
247
|
+
|
|
248
|
+
# With options
|
|
249
|
+
version.render(:html, theme: :github, line_numbers: true)
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
### Diff JSON Structure
|
|
253
|
+
|
|
254
|
+
```json
|
|
255
|
+
{
|
|
256
|
+
"content_type": "markdown",
|
|
257
|
+
"from_version": 2,
|
|
258
|
+
"to_version": 5,
|
|
259
|
+
"stats": { "additions": 12, "deletions": 3, "modifications": 5 },
|
|
260
|
+
"changes": [
|
|
261
|
+
{
|
|
262
|
+
"type": "addition",
|
|
263
|
+
"position": { "line": 15 },
|
|
264
|
+
"content": "New paragraph text"
|
|
265
|
+
},
|
|
266
|
+
{
|
|
267
|
+
"type": "modification",
|
|
268
|
+
"position": { "line": 8 },
|
|
269
|
+
"old_content": "Original text",
|
|
270
|
+
"new_content": "Updated text"
|
|
271
|
+
}
|
|
272
|
+
]
|
|
273
|
+
}
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
---
|
|
277
|
+
|
|
278
|
+
## Phase 3 — Comments & Inline Annotations
|
|
279
|
+
|
|
280
|
+
**Complexity note:** Document-level comments with threading are straightforward.
|
|
281
|
+
Range-anchored inline annotations add significant complexity (anchor migration,
|
|
282
|
+
orphan detection). Build document-level first, add range anchoring second.
|
|
283
|
+
If range anchoring proves too complex, ship without it — document-level
|
|
284
|
+
comments are still valuable.
|
|
285
|
+
|
|
286
|
+
### Database Tables
|
|
287
|
+
|
|
288
|
+
```
|
|
289
|
+
docsmith_comments
|
|
290
|
+
id :bigint, PK
|
|
291
|
+
version_id :bigint, FK -> docsmith_versions
|
|
292
|
+
parent_id :bigint, FK -> docsmith_comments, nullable # threading
|
|
293
|
+
author_type :string
|
|
294
|
+
author_id :bigint
|
|
295
|
+
body :text
|
|
296
|
+
anchor_type :string # "document" or "range"
|
|
297
|
+
anchor_data :jsonb, default: {}
|
|
298
|
+
# For range anchors: { start_offset: 45, end_offset: 72, content_hash: "abc123" }
|
|
299
|
+
resolved :boolean, default: false
|
|
300
|
+
resolved_by_type :string, nullable
|
|
301
|
+
resolved_by_id :bigint, nullable
|
|
302
|
+
resolved_at :datetime, nullable
|
|
303
|
+
created_at :datetime
|
|
304
|
+
updated_at :datetime
|
|
305
|
+
|
|
306
|
+
index: [version_id]
|
|
307
|
+
index: [parent_id]
|
|
308
|
+
index: [author_type, author_id]
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
### Comment API
|
|
312
|
+
|
|
313
|
+
```ruby
|
|
314
|
+
# Document-level comment
|
|
315
|
+
article.add_comment!(
|
|
316
|
+
version: 3,
|
|
317
|
+
body: "Looks good overall",
|
|
318
|
+
author: current_user
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
# Inline annotation (range-anchored)
|
|
322
|
+
article.add_comment!(
|
|
323
|
+
version: 3,
|
|
324
|
+
body: "This needs a citation",
|
|
325
|
+
author: current_user,
|
|
326
|
+
anchor: { start_offset: 45, end_offset: 72 }
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
# Reply to comment (threading)
|
|
330
|
+
article.add_comment!(
|
|
331
|
+
version: 3,
|
|
332
|
+
body: "Added the citation",
|
|
333
|
+
author: current_user,
|
|
334
|
+
parent: original_comment
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
# Resolve
|
|
338
|
+
comment.resolve!(by: current_user)
|
|
339
|
+
|
|
340
|
+
# Query
|
|
341
|
+
article.comments # all comments across versions
|
|
342
|
+
article.comments_on(version: 3) # comments on specific version
|
|
343
|
+
article.comments_on(version: 3, type: :range) # only inline annotations
|
|
344
|
+
article.unresolved_comments # across all versions
|
|
345
|
+
|
|
346
|
+
# Comment migration across versions
|
|
347
|
+
article.migrate_comments!(from: 3, to: 4)
|
|
348
|
+
# Attempts to re-anchor range comments to new version content
|
|
349
|
+
# Uses content_hash for fuzzy matching when offsets shift
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
### Anchor Strategy for Range Comments
|
|
353
|
+
|
|
354
|
+
```
|
|
355
|
+
When content changes between versions:
|
|
356
|
+
1. Try exact offset match
|
|
357
|
+
2. If content at offset differs, use content_hash to find relocated text
|
|
358
|
+
3. If text is gone, mark comment as "orphaned" (still visible, flagged)
|
|
359
|
+
|
|
360
|
+
anchor_data schema:
|
|
361
|
+
{
|
|
362
|
+
start_offset: Integer, # character offset from document start
|
|
363
|
+
end_offset: Integer, # character offset end
|
|
364
|
+
content_hash: String, # SHA256 of the anchored text snippet
|
|
365
|
+
anchored_text: String, # the original selected text (for display)
|
|
366
|
+
status: "active" | "drifted" | "orphaned"
|
|
367
|
+
}
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
### Events (Phase 3)
|
|
371
|
+
|
|
372
|
+
```ruby
|
|
373
|
+
# Additional hooks
|
|
374
|
+
config.on(:comment_added) { |e| }
|
|
375
|
+
config.on(:comment_resolved) { |e| }
|
|
376
|
+
config.on(:comment_orphaned) { |e| }
|
|
377
|
+
|
|
378
|
+
# AS::Notifications
|
|
379
|
+
"comment_added.docsmith"
|
|
380
|
+
"comment_resolved.docsmith"
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
---
|
|
384
|
+
|
|
385
|
+
## Phase 4 — Branching & Merging
|
|
386
|
+
|
|
387
|
+
**Complexity note:** This is the hardest phase. Three-way merge with
|
|
388
|
+
conflict detection is non-trivial. For v1, start with branch creation
|
|
389
|
+
and simple fast-forward merges (branch head replaces main when main
|
|
390
|
+
hasn't changed since fork). Full three-way merge with conflict
|
|
391
|
+
resolution can come in v1.1+.
|
|
392
|
+
|
|
393
|
+
### Database Tables
|
|
394
|
+
|
|
395
|
+
```
|
|
396
|
+
docsmith_branches
|
|
397
|
+
id :bigint, PK
|
|
398
|
+
document_id :bigint, FK -> docsmith_documents
|
|
399
|
+
name :string
|
|
400
|
+
source_version :bigint, FK -> docsmith_versions # where it forked from
|
|
401
|
+
head_version :bigint, FK -> docsmith_versions, nullable
|
|
402
|
+
author_type :string
|
|
403
|
+
author_id :bigint
|
|
404
|
+
status :string # "active", "merged", "abandoned"
|
|
405
|
+
merged_at :datetime, nullable
|
|
406
|
+
created_at :datetime
|
|
407
|
+
updated_at :datetime
|
|
408
|
+
|
|
409
|
+
index: [document_id, name], unique: true
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
### Branch API
|
|
413
|
+
|
|
414
|
+
```ruby
|
|
415
|
+
# Create branch from version
|
|
416
|
+
branch = article.create_branch!(
|
|
417
|
+
name: "experimental-intro",
|
|
418
|
+
from_version: 3,
|
|
419
|
+
author: current_user
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
# Save to branch
|
|
423
|
+
article.save_version!(author: current_user, branch: branch)
|
|
424
|
+
|
|
425
|
+
# List branches
|
|
426
|
+
article.branches # => all branches
|
|
427
|
+
article.active_branches # => non-merged, non-abandoned
|
|
428
|
+
|
|
429
|
+
# Read branch
|
|
430
|
+
branch.versions # => versions on this branch
|
|
431
|
+
branch.head # => latest version on branch
|
|
432
|
+
branch.source_version # => version it forked from
|
|
433
|
+
|
|
434
|
+
# Diff branch against main
|
|
435
|
+
diff = branch.diff_from_source # head vs fork point
|
|
436
|
+
diff = branch.diff_against_current # head vs current main version
|
|
437
|
+
|
|
438
|
+
# Merge
|
|
439
|
+
merge_result = article.merge_branch!(branch, author: current_user)
|
|
440
|
+
merge_result.success? # => true/false
|
|
441
|
+
merge_result.conflicts # => [] or array of conflict descriptions
|
|
442
|
+
merge_result.merged_version # => new Docsmith::Version if success
|
|
443
|
+
|
|
444
|
+
# Conflict handling (content-type specific)
|
|
445
|
+
# For markdown: line-level conflict markers (like git)
|
|
446
|
+
# For JSON: key-path conflicts listed
|
|
447
|
+
# For HTML: block-level conflicts
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
### Merge Strategy
|
|
451
|
+
|
|
452
|
+
```
|
|
453
|
+
1. Three-way merge: source_version (common ancestor), branch head, main head
|
|
454
|
+
2. Content-type specific merger:
|
|
455
|
+
- Markdown: line-based three-way merge
|
|
456
|
+
- JSON: deep merge with conflict detection on same-key changes
|
|
457
|
+
- HTML: block-level merge (paragraph/div granularity)
|
|
458
|
+
3. If auto-merge succeeds: create new version on main
|
|
459
|
+
4. If conflicts: return MergeResult with conflicts, no version created
|
|
460
|
+
5. Manual resolution: user edits content, calls save_version! normally
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
### Events (Phase 4)
|
|
464
|
+
|
|
465
|
+
```ruby
|
|
466
|
+
config.on(:branch_created) { |e| }
|
|
467
|
+
config.on(:branch_merged) { |e| }
|
|
468
|
+
config.on(:merge_conflict) { |e| }
|
|
469
|
+
|
|
470
|
+
"branch_created.docsmith"
|
|
471
|
+
"branch_merged.docsmith"
|
|
472
|
+
"merge_conflict.docsmith"
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
---
|
|
476
|
+
|
|
477
|
+
## Gem File Structure
|
|
478
|
+
|
|
479
|
+
```
|
|
480
|
+
docsmith/
|
|
481
|
+
├── docsmith.gemspec
|
|
482
|
+
├── Gemfile
|
|
483
|
+
├── README.md
|
|
484
|
+
├── LICENSE
|
|
485
|
+
├── Rakefile
|
|
486
|
+
├── lib/
|
|
487
|
+
│ ├── docsmith.rb # main entry, autoloads
|
|
488
|
+
│ ├── docsmith/
|
|
489
|
+
│ │ ├── version.rb # gem version constant
|
|
490
|
+
│ │ ├── configuration.rb # Docsmith.configure block
|
|
491
|
+
│ │ ├── errors.rb # custom error classes
|
|
492
|
+
│ │ ├── versionable.rb # ActiveRecord mixin
|
|
493
|
+
│ │ ├── document.rb # standalone document model
|
|
494
|
+
│ │ ├── version_record.rb # Docsmith::Version AR model
|
|
495
|
+
│ │ ├── version_tag.rb # Docsmith::VersionTag AR model
|
|
496
|
+
│ │ ├── version_manager.rb # service object for versioning
|
|
497
|
+
│ │ ├── auto_save.rb # debounce logic
|
|
498
|
+
│ │ ├── diff/
|
|
499
|
+
│ │ │ ├── engine.rb # Docsmith::Diff.between
|
|
500
|
+
│ │ │ ├── result.rb # diff result object
|
|
501
|
+
│ │ │ └── renderers/
|
|
502
|
+
│ │ │ ├── base.rb # line-level via diff-lcs (handles all types in v1)
|
|
503
|
+
│ │ │ └── registry.rb # renderer registration for future custom renderers
|
|
504
|
+
│ │ ├── comments/
|
|
505
|
+
│ │ │ ├── comment.rb # AR model
|
|
506
|
+
│ │ │ ├── manager.rb # service object
|
|
507
|
+
│ │ │ ├── anchor.rb # range anchor logic
|
|
508
|
+
│ │ │ └── migrator.rb # cross-version migration
|
|
509
|
+
│ │ ├── branches/
|
|
510
|
+
│ │ │ ├── branch.rb # AR model
|
|
511
|
+
│ │ │ ├── manager.rb # create/merge service
|
|
512
|
+
│ │ │ └── merger.rb # three-way merge logic
|
|
513
|
+
│ │ ├── events/
|
|
514
|
+
│ │ │ ├── hook_registry.rb # callback hooks
|
|
515
|
+
│ │ │ ├── notifier.rb # AS::Notifications wrapper
|
|
516
|
+
│ │ │ └── event.rb # event payload object
|
|
517
|
+
│ │ └── rendering/
|
|
518
|
+
│ │ ├── html_renderer.rb
|
|
519
|
+
│ │ └── json_renderer.rb
|
|
520
|
+
│ └── generators/
|
|
521
|
+
│ └── docsmith/
|
|
522
|
+
│ └── install/
|
|
523
|
+
│ ├── install_generator.rb
|
|
524
|
+
│ └── templates/
|
|
525
|
+
│ ├── create_docsmith_tables.rb.erb
|
|
526
|
+
│ └── docsmith_initializer.rb.erb
|
|
527
|
+
├── spec/
|
|
528
|
+
│ ├── spec_helper.rb
|
|
529
|
+
│ ├── docsmith/
|
|
530
|
+
│ │ ├── versionable_spec.rb
|
|
531
|
+
│ │ ├── version_manager_spec.rb
|
|
532
|
+
│ │ ├── auto_save_spec.rb
|
|
533
|
+
│ │ ├── diff/
|
|
534
|
+
│ │ │ ├── engine_spec.rb
|
|
535
|
+
│ │ │ └── renderers/
|
|
536
|
+
│ │ │ └── base_renderer_spec.rb
|
|
537
|
+
│ │ ├── comments/
|
|
538
|
+
│ │ │ ├── comment_spec.rb
|
|
539
|
+
│ │ │ ├── manager_spec.rb
|
|
540
|
+
│ │ │ └── migrator_spec.rb
|
|
541
|
+
│ │ └── branches/
|
|
542
|
+
│ │ ├── branch_spec.rb
|
|
543
|
+
│ │ ├── manager_spec.rb
|
|
544
|
+
│ │ └── merger_spec.rb
|
|
545
|
+
│ └── support/
|
|
546
|
+
│ ├── schema.rb # test DB schema
|
|
547
|
+
│ └── models.rb # test AR models
|
|
548
|
+
└── .rspec
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
---
|
|
552
|
+
|
|
553
|
+
## Global Configuration
|
|
554
|
+
|
|
555
|
+
Global config sets defaults for all models. Per-class `docsmith_config`
|
|
556
|
+
blocks override these. Resolution: per-class > global > gem defaults.
|
|
557
|
+
|
|
558
|
+
```ruby
|
|
559
|
+
# config/initializers/docsmith.rb
|
|
560
|
+
Docsmith.configure do |config|
|
|
561
|
+
# Gem defaults (shown here) — global config overrides these
|
|
562
|
+
# Per-class docsmith_config blocks override global config
|
|
563
|
+
config.default_content_type = :markdown # gem default: :markdown
|
|
564
|
+
config.default_debounce = 30.seconds # gem default: 30.seconds
|
|
565
|
+
config.max_versions = nil # gem default: nil (unlimited)
|
|
566
|
+
config.auto_save = true # gem default: true
|
|
567
|
+
config.default_content_field = :body # gem default: :body
|
|
568
|
+
|
|
569
|
+
# Table name prefix (if needed)
|
|
570
|
+
config.table_prefix = "docsmith"
|
|
571
|
+
|
|
572
|
+
# Diff rendering defaults
|
|
573
|
+
config.diff_context_lines = 3 # lines of context in diffs
|
|
574
|
+
|
|
575
|
+
# Event hooks
|
|
576
|
+
config.on(:version_created) { |event| }
|
|
577
|
+
config.on(:version_restored) { |event| }
|
|
578
|
+
config.on(:version_tagged) { |event| }
|
|
579
|
+
config.on(:comment_added) { |event| }
|
|
580
|
+
config.on(:branch_created) { |event| }
|
|
581
|
+
config.on(:branch_merged) { |event| }
|
|
582
|
+
end
|
|
583
|
+
```
|
|
584
|
+
|
|
585
|
+
---
|
|
586
|
+
|
|
587
|
+
## Dependencies
|
|
588
|
+
|
|
589
|
+
```ruby
|
|
590
|
+
# gemspec
|
|
591
|
+
spec.add_dependency "activerecord", ">= 7.0"
|
|
592
|
+
spec.add_dependency "activesupport", ">= 7.0"
|
|
593
|
+
spec.add_dependency "diff-lcs", "~> 1.5" # pure Ruby diffing, zero system deps
|
|
594
|
+
# Note: diffy was considered but adds Unix diff binary dependency
|
|
595
|
+
# for ~70% coverage across content types. diff-lcs is pure Ruby,
|
|
596
|
+
# already a transitive dep via rspec, and gives full control over
|
|
597
|
+
# rendering. Custom renderers can be swapped in per content type later.
|
|
598
|
+
|
|
599
|
+
spec.add_development_dependency "rspec", "~> 3.12"
|
|
600
|
+
spec.add_development_dependency "sqlite3"
|
|
601
|
+
spec.add_development_dependency "factory_bot", "~> 6.0"
|
|
602
|
+
spec.add_development_dependency "rubocop", "~> 1.50"
|
|
603
|
+
```
|
|
604
|
+
|
|
605
|
+
---
|
|
606
|
+
|
|
607
|
+
## Claude Code Usage Instructions
|
|
608
|
+
|
|
609
|
+
### How to use this spec
|
|
610
|
+
|
|
611
|
+
Feed each phase to Claude Code separately:
|
|
612
|
+
|
|
613
|
+
```
|
|
614
|
+
Phase 1: "Build the Docsmith gem Phase 1 from this spec: [paste Phase 1 section]"
|
|
615
|
+
Phase 2: "Add Phase 2 (diff & rendering) to the existing Docsmith gem: [paste Phase 2]"
|
|
616
|
+
Phase 3: "Add Phase 3 (comments) to Docsmith: [paste Phase 3]"
|
|
617
|
+
Phase 4: "Add Phase 4 (branching & merging) to Docsmith: [paste Phase 4]"
|
|
618
|
+
```
|
|
619
|
+
|
|
620
|
+
Each phase should include its database tables, API, events, and tests.
|
|
621
|
+
Always include the file structure section for context on where files go.
|
|
622
|
+
|
|
623
|
+
### Key constraints for Claude Code
|
|
624
|
+
- Every public method must have RDoc documentation
|
|
625
|
+
- Every class must have corresponding spec file
|
|
626
|
+
- Use `frozen_string_literal: true` in all Ruby files
|
|
627
|
+
- Follow Ruby community style guide
|
|
628
|
+
- No monkey-patching of core classes
|
|
629
|
+
- All ActiveRecord queries must be scope-based (no raw SQL)
|
|
630
|
+
- Events must fire both hooks AND AS::Notifications for every action
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Docsmith
|
|
4
|
+
# Applies debounce logic before delegating to VersionManager.save!
|
|
5
|
+
# Extracted for independent testability.
|
|
6
|
+
module AutoSave
|
|
7
|
+
# @param document [Docsmith::Document]
|
|
8
|
+
# @param author [Object, nil]
|
|
9
|
+
# @param config [Hash] resolved config
|
|
10
|
+
# @return [Docsmith::DocumentVersion, nil] nil if within debounce or content unchanged
|
|
11
|
+
def self.call(document, author:, config:)
|
|
12
|
+
return nil if within_debounce?(document, config)
|
|
13
|
+
|
|
14
|
+
VersionManager.save!(document, author: author, config: config)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Returns true if the debounce window has not yet elapsed.
|
|
18
|
+
# Public so specs can assert on timing logic without mocking Time.
|
|
19
|
+
# @param document [Docsmith::Document]
|
|
20
|
+
# @param config [Hash] resolved config
|
|
21
|
+
# @return [Boolean]
|
|
22
|
+
def self.within_debounce?(document, config)
|
|
23
|
+
last_saved = document.last_versioned_at
|
|
24
|
+
return false if last_saved.nil?
|
|
25
|
+
|
|
26
|
+
Time.current < last_saved + config[:debounce].to_i
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|