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.
Files changed (45) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rspec_status +212 -0
  4. data/CHANGELOG.md +5 -0
  5. data/CODE_OF_CONDUCT.md +132 -0
  6. data/LICENSE.txt +21 -0
  7. data/README.md +66 -0
  8. data/Rakefile +8 -0
  9. data/USAGE.md +510 -0
  10. data/docs/superpowers/plans/2026-04-01-docsmith-full-plan.md +6459 -0
  11. data/docs/superpowers/plans/2026-04-08-parsers-remove-branches-docs.md +2112 -0
  12. data/docs/superpowers/specs/2026-04-01-docsmith-phase1-design.md +340 -0
  13. data/docsmith_spec.md +630 -0
  14. data/lib/docsmith/auto_save.rb +29 -0
  15. data/lib/docsmith/comments/anchor.rb +68 -0
  16. data/lib/docsmith/comments/comment.rb +44 -0
  17. data/lib/docsmith/comments/manager.rb +73 -0
  18. data/lib/docsmith/comments/migrator.rb +64 -0
  19. data/lib/docsmith/configuration.rb +95 -0
  20. data/lib/docsmith/diff/engine.rb +39 -0
  21. data/lib/docsmith/diff/parsers/html.rb +64 -0
  22. data/lib/docsmith/diff/parsers/markdown.rb +60 -0
  23. data/lib/docsmith/diff/renderers/base.rb +62 -0
  24. data/lib/docsmith/diff/renderers/registry.rb +41 -0
  25. data/lib/docsmith/diff/renderers.rb +10 -0
  26. data/lib/docsmith/diff/result.rb +77 -0
  27. data/lib/docsmith/diff.rb +6 -0
  28. data/lib/docsmith/document.rb +44 -0
  29. data/lib/docsmith/document_version.rb +50 -0
  30. data/lib/docsmith/errors.rb +18 -0
  31. data/lib/docsmith/events/event.rb +19 -0
  32. data/lib/docsmith/events/hook_registry.rb +14 -0
  33. data/lib/docsmith/events/notifier.rb +22 -0
  34. data/lib/docsmith/rendering/html_renderer.rb +36 -0
  35. data/lib/docsmith/rendering/json_renderer.rb +29 -0
  36. data/lib/docsmith/version.rb +5 -0
  37. data/lib/docsmith/version_manager.rb +143 -0
  38. data/lib/docsmith/version_tag.rb +25 -0
  39. data/lib/docsmith/versionable.rb +252 -0
  40. data/lib/docsmith.rb +52 -0
  41. data/lib/generators/docsmith/install/install_generator.rb +27 -0
  42. data/lib/generators/docsmith/install/templates/create_docsmith_tables.rb.erb +64 -0
  43. data/lib/generators/docsmith/install/templates/docsmith_initializer.rb.erb +19 -0
  44. data/sig/docsmith.rbs +4 -0
  45. 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