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/USAGE.md ADDED
@@ -0,0 +1,510 @@
1
+ # Docsmith Usage Guide
2
+
3
+ Docsmith adds snapshot-based versioning, format-aware diffs, and inline comments to any
4
+ ActiveRecord model. It stores all data in your existing database — no external services.
5
+
6
+ ---
7
+
8
+ ## Table of Contents
9
+
10
+ 1. [Installation](#1-installation)
11
+ 2. [Setup — Migration](#2-setup--migration)
12
+ 3. [Setup — Include Versionable](#3-setup--include-versionable)
13
+ 4. [Per-Class Configuration](#4-per-class-configuration)
14
+ 5. [Global Configuration](#5-global-configuration)
15
+ 6. [Saving Versions](#6-saving-versions)
16
+ 7. [Auto-Save and Debounce](#7-auto-save-and-debounce)
17
+ 8. [Querying Versions](#8-querying-versions)
18
+ 9. [Restoring Versions](#9-restoring-versions)
19
+ 10. [Tagging Versions](#10-tagging-versions)
20
+ 11. [Diffs](#11-diffs)
21
+ 12. [Comments](#12-comments)
22
+ 13. [Events and Hooks](#13-events-and-hooks)
23
+ 14. [Standalone Document API](#14-standalone-document-api)
24
+ 15. [Configuration Reference](#15-configuration-reference)
25
+
26
+ ---
27
+
28
+ ## 1. Installation
29
+
30
+ Add to your `Gemfile`:
31
+
32
+ ```ruby
33
+ gem "docsmith"
34
+ ```
35
+
36
+ Then:
37
+
38
+ ```bash
39
+ bundle install
40
+ ```
41
+
42
+ ---
43
+
44
+ ## 2. Setup — Migration
45
+
46
+ Run the install generator to create the migration:
47
+
48
+ ```bash
49
+ rails generate docsmith:install
50
+ rails db:migrate
51
+ ```
52
+
53
+ This creates four tables:
54
+
55
+ | Table | Purpose |
56
+ |-------------------------|----------------------------------------------|
57
+ | `docsmith_documents` | One record per versioned model instance |
58
+ | `docsmith_versions` | Content snapshots (immutable) |
59
+ | `docsmith_version_tags` | Named tags on specific versions |
60
+ | `docsmith_comments` | Inline and document-level comments |
61
+
62
+ ---
63
+
64
+ ## 3. Setup — Include Versionable
65
+
66
+ Add `include Docsmith::Versionable` to any ActiveRecord model. Optionally configure
67
+ it with `docsmith_config`:
68
+
69
+ ```ruby
70
+ class Article < ApplicationRecord
71
+ include Docsmith::Versionable
72
+
73
+ docsmith_config do
74
+ content_field :body # which column holds the document content
75
+ content_type :markdown # :markdown, :html, or :json
76
+ end
77
+ end
78
+ ```
79
+
80
+ That is all you need. Docsmith automatically creates a shadow `Docsmith::Document`
81
+ record the first time a version is saved for each model instance.
82
+
83
+ ---
84
+
85
+ ## 4. Per-Class Configuration
86
+
87
+ `docsmith_config` accepts a block that can set any of the following keys:
88
+
89
+ ```ruby
90
+ class LegalDocument < ApplicationRecord
91
+ include Docsmith::Versionable
92
+
93
+ docsmith_config do
94
+ content_field :body # column to snapshot (default: :body)
95
+ content_type :html # :markdown (default), :html, :json
96
+ auto_save false # disable auto-save callback (default: true)
97
+ debounce 60 # seconds between auto-saves (default: 30)
98
+ max_versions 50 # cap on stored versions per document (default: nil = unlimited)
99
+ content_extractor ->(r) { r.body.to_s.strip } # override content_field with a proc
100
+ end
101
+ end
102
+ ```
103
+
104
+ **`content_extractor`** is useful when the field you want to version is not a plain
105
+ string column:
106
+
107
+ ```ruby
108
+ docsmith_config do
109
+ content_field :body_data # ActiveStorage attachment or JSONB column
110
+ content_type :markdown
111
+ content_extractor ->(record) { record.body_data.to_plain_text }
112
+ end
113
+ ```
114
+
115
+ ---
116
+
117
+ ## 5. Global Configuration
118
+
119
+ Set defaults for the whole app in `config/initializers/docsmith.rb`:
120
+
121
+ ```ruby
122
+ Docsmith.configure do |config|
123
+ config.default_content_field = :body
124
+ config.default_content_type = :markdown
125
+ config.auto_save = true
126
+ config.default_debounce = 30 # seconds
127
+ config.max_versions = nil # nil = unlimited
128
+ end
129
+ ```
130
+
131
+ Resolution order: **per-class `docsmith_config`** > **global `Docsmith.configure`** > **gem defaults**.
132
+
133
+ ---
134
+
135
+ ## 6. Saving Versions
136
+
137
+ Call `save_version!` to take an explicit snapshot:
138
+
139
+ ```ruby
140
+ article = Article.find(1)
141
+ article.body = "Updated content here."
142
+ article.save!
143
+
144
+ version = article.save_version!(author: current_user, summary: "Fixed typo in intro")
145
+ # => #<Docsmith::DocumentVersion version_number: 3, content_type: "markdown", ...>
146
+ ```
147
+
148
+ - Returns the new `DocumentVersion` record.
149
+ - Returns `nil` if the content has not changed since the last snapshot.
150
+ - Raises `Docsmith::InvalidContentField` if `content_field` returns a non-String and
151
+ no `content_extractor` is configured.
152
+
153
+ ---
154
+
155
+ ## 7. Auto-Save and Debounce
156
+
157
+ When `auto_save: true` (the default), Docsmith hooks into ActiveRecord's `after_save`
158
+ callback and automatically takes a snapshot after every model save — subject to the
159
+ debounce window.
160
+
161
+ ```ruby
162
+ article.body = "New draft"
163
+ article.save! # triggers auto_save_version! internally
164
+ ```
165
+
166
+ The **debounce** prevents a snapshot from being created if another snapshot was already
167
+ taken within the last N seconds (default: 30). This avoids flooding the version history
168
+ when a user is rapidly typing and saving.
169
+
170
+ You can also call `auto_save_version!` directly:
171
+
172
+ ```ruby
173
+ article.auto_save_version!(author: current_user)
174
+ ```
175
+
176
+ To disable auto-save for a class:
177
+
178
+ ```ruby
179
+ docsmith_config { auto_save false }
180
+ ```
181
+
182
+ ---
183
+
184
+ ## 8. Querying Versions
185
+
186
+ ```ruby
187
+ # All versions, ordered by version_number ascending
188
+ article.versions
189
+ # => ActiveRecord::Relation<Docsmith::DocumentVersion>
190
+
191
+ # Latest version
192
+ article.current_version
193
+ # => #<Docsmith::DocumentVersion version_number: 5, ...>
194
+
195
+ # Specific version by number
196
+ article.version(3)
197
+ # => #<Docsmith::DocumentVersion version_number: 3, ...>
198
+
199
+ # Inspect content
200
+ article.version(2).content # => "Body text at v2"
201
+ article.version(2).content_type # => "markdown"
202
+ article.version(2).author # => #<User id: 1, ...>
203
+ article.version(2).change_summary # => "Second draft"
204
+ article.version(2).created_at # => 2026-03-01 14:22:00 UTC
205
+
206
+ # Render a version's content
207
+ article.version(2).render(:html) # => "<p>Body text at v2</p>"
208
+ article.version(2).render(:json) # => '{"version":2,"content":"..."}'
209
+ ```
210
+
211
+ ---
212
+
213
+ ## 9. Restoring Versions
214
+
215
+ Restore creates a **new version** whose content matches an older snapshot. It never
216
+ mutates existing version records.
217
+
218
+ ```ruby
219
+ restored = article.restore_version!(2, author: current_user)
220
+ # => #<Docsmith::DocumentVersion version_number: 6, change_summary: "Restored from v2", ...>
221
+
222
+ article.reload.body # => the body content from v2
223
+ ```
224
+
225
+ - The model's `content_field` column is updated via `update_column` (bypasses callbacks
226
+ to avoid a duplicate auto-save).
227
+ - Fires the `:version_restored` event hook (see §13).
228
+ - Raises `Docsmith::VersionNotFound` if the version number does not exist.
229
+
230
+ ---
231
+
232
+ ## 10. Tagging Versions
233
+
234
+ Tags are named pointers to specific versions, unique per document.
235
+
236
+ ```ruby
237
+ # Create a tag
238
+ article.tag_version!(3, name: "v1.0-release", author: current_user)
239
+
240
+ # Look up a version by tag name
241
+ v = article.tagged_version("v1.0-release")
242
+ # => #<Docsmith::DocumentVersion version_number: 3, ...>
243
+
244
+ # List tag names on a version
245
+ article.version_tags(3)
246
+ # => ["v1.0-release", "stable"]
247
+ ```
248
+
249
+ - Raises `Docsmith::TagAlreadyExists` if the name is already used on this document.
250
+ - Raises `Docsmith::VersionNotFound` if the version number does not exist.
251
+
252
+ **Interaction with `max_versions`:** Tagged versions are never pruned automatically.
253
+ If all versions are tagged and a prune would be needed, `Docsmith::MaxVersionsExceeded`
254
+ is raised.
255
+
256
+ ---
257
+
258
+ ## 11. Diffs
259
+
260
+ Docsmith computes diffs between any two versions. The parser used depends on the
261
+ document's `content_type`.
262
+
263
+ ### Diff from version N to current
264
+
265
+ ```ruby
266
+ result = article.diff_from(1)
267
+ # => #<Docsmith::Diff::Result from_version: 1, to_version: 5, ...>
268
+
269
+ result.additions # => integer count of added tokens
270
+ result.deletions # => integer count of removed tokens
271
+ result.to_html # => HTML string with <ins>/<del> markup
272
+ result.to_json # => JSON string with stats and changes array
273
+ ```
274
+
275
+ ### Diff between two named versions
276
+
277
+ ```ruby
278
+ result = article.diff_between(2, 4)
279
+ ```
280
+
281
+ ### Format-aware parsers
282
+
283
+ | `content_type` | Parser | Token unit |
284
+ |----------------|--------|-----------|
285
+ | `markdown` | `Docsmith::Diff::Parsers::Markdown` | Each whitespace-delimited word; newline runs are one token |
286
+ | `html` | `Docsmith::Diff::Parsers::Html` | Each HTML tag (including attributes) is one token; words in text are separate tokens |
287
+ | `json` | `Docsmith::Diff::Renderers::Base` | Line-level (whole lines) |
288
+
289
+ **Markdown example:**
290
+
291
+ ```ruby
292
+ # v1 content: "The quick brown fox"
293
+ # v2 content: "The quick red fox"
294
+ result = article.diff_between(1, 2)
295
+ result.changes
296
+ # => [{ type: :modification, line: 3, old_content: "brown", new_content: "red" }]
297
+ result.additions # => 0
298
+ result.deletions # => 0
299
+ ```
300
+
301
+ **HTML example:**
302
+
303
+ ```ruby
304
+ # v1 content: "<p>Hello world</p>"
305
+ # v2 content: "<p>Hello world</p><p>New paragraph</p>"
306
+ # old tokens: ["<p>", "Hello", "world", "</p>"]
307
+ # new tokens: ["<p>", "Hello", "world", "</p>", "<p>", "New", "paragraph", "</p>"]
308
+ # LCS: first 4 tokens match exactly → 4 additions: "<p>", "New", "paragraph", "</p>"
309
+ result = article.diff_between(1, 2)
310
+ result.additions # => 4
311
+ ```
312
+
313
+ ### to_html output
314
+
315
+ ```ruby
316
+ result.to_html
317
+ # => '<div class="docsmith-diff">
318
+ # <ins class="docsmith-addition">Ruby</ins>
319
+ # <del class="docsmith-deletion">Python</del>
320
+ # </div>'
321
+ ```
322
+
323
+ ### to_json output
324
+
325
+ ```ruby
326
+ JSON.parse(result.to_json)
327
+ # => {
328
+ # "content_type" => "markdown",
329
+ # "from_version" => 1,
330
+ # "to_version" => 3,
331
+ # "stats" => { "additions" => 2, "deletions" => 1 },
332
+ # "changes" => [
333
+ # { "type" => "addition", "position" => { "line" => 5 }, "content" => "Ruby" },
334
+ # { "type" => "deletion", "position" => { "line" => 3 }, "content" => "Python" },
335
+ # { "type" => "modification", "position" => { "line" => 7 }, "old_content" => "foo", "new_content" => "bar" }
336
+ # ]
337
+ # }
338
+ ```
339
+
340
+ ---
341
+
342
+ ## 12. Comments
343
+
344
+ Comments can be attached to a specific version. They are either **document-level** (no
345
+ position) or **range-anchored** (tied to a character offset range).
346
+
347
+ ### Add a comment
348
+
349
+ ```ruby
350
+ # Document-level comment
351
+ comment = article.add_comment!(
352
+ version: 2,
353
+ body: "This section needs a citation.",
354
+ author: current_user
355
+ )
356
+ comment.anchor_type # => "document"
357
+
358
+ # Range-anchored (inline) comment — offsets into the version's content string
359
+ comment = article.add_comment!(
360
+ version: 2,
361
+ body: "Cite this claim.",
362
+ author: current_user,
363
+ anchor: { start_offset: 42, end_offset: 78 }
364
+ )
365
+ comment.anchor_type # => "range"
366
+ comment.anchor_data["start_offset"] # => 42
367
+ comment.anchor_data["anchored_text"] # => the substring from offset 42–78
368
+ ```
369
+
370
+ ### Thread replies
371
+
372
+ ```ruby
373
+ reply = article.add_comment!(
374
+ version: 2,
375
+ body: "Good point, fixing now.",
376
+ author: other_user,
377
+ parent: comment
378
+ )
379
+ comment.replies # => [reply]
380
+ reply.parent # => comment
381
+ ```
382
+
383
+ ### Query comments
384
+
385
+ ```ruby
386
+ # All comments across all versions (AR relation)
387
+ article.comments
388
+
389
+ # Comments on a specific version
390
+ article.comments_on(version: 2)
391
+
392
+ # Filter by type
393
+ article.comments_on(version: 2, type: :range) # inline only
394
+ article.comments_on(version: 2, type: :document) # document-level only
395
+
396
+ # Unresolved comments across all versions
397
+ article.unresolved_comments
398
+ ```
399
+
400
+ ### Resolve a comment
401
+
402
+ ```ruby
403
+ Docsmith::Comments::Manager.resolve!(comment, by: current_user)
404
+ comment.reload.resolved # => true
405
+ comment.resolved_by # => current_user
406
+ comment.resolved_at # => Time
407
+ ```
408
+
409
+ ### Migrate comments between versions
410
+
411
+ When a new version is saved, document-level comments from the previous version can be
412
+ migrated forward:
413
+
414
+ ```ruby
415
+ article.migrate_comments!(from: 2, to: 3)
416
+ # Copies document-level (non-range) comments from v2 to v3.
417
+ # Range comments are not migrated — their offsets may no longer be valid.
418
+ ```
419
+
420
+ ---
421
+
422
+ ## 13. Events and Hooks
423
+
424
+ Docsmith fires synchronous events you can subscribe to via `Docsmith.configure`.
425
+
426
+ ```ruby
427
+ Docsmith.configure do |config|
428
+ config.on(:version_created) do |event|
429
+ Rails.logger.info "[Docsmith] v#{event.version.version_number} saved on #{event.document.title}"
430
+ AuditLog.create!(action: "version_created", record: event.record)
431
+ end
432
+
433
+ config.on(:version_restored) do |event|
434
+ Rails.logger.info "[Docsmith] Restored to v#{event.version.version_number}"
435
+ end
436
+
437
+ config.on(:version_tagged) do |event|
438
+ Rails.logger.info "[Docsmith] Tagged v#{event.version.version_number} as '#{event.tag_name}'"
439
+ end
440
+ end
441
+ ```
442
+
443
+ **Event payload** (`event` is a `Docsmith::Events::Event`):
444
+
445
+ | Field | Type | Always present |
446
+ |----------------|--------------------------------------|----------------|
447
+ | `event.record` | The originating AR model (or Document if standalone) | yes |
448
+ | `event.document` | `Docsmith::Document` | yes |
449
+ | `event.version` | `Docsmith::DocumentVersion` | yes |
450
+ | `event.author` | whatever you passed as `author:` | yes |
451
+ | `event.tag_name` | String (`:version_tagged` only) | no |
452
+ | `event.from_version` | DocumentVersion (`:version_restored` only) | no |
453
+
454
+ Hooks fire before `ActiveSupport::Notifications` so they are synchronous and blocking.
455
+ Keep hooks fast.
456
+
457
+ ---
458
+
459
+ ## 14. Standalone Document API
460
+
461
+ `Docsmith::Versionable` is a convenience wrapper. You can use `Docsmith::Document` and
462
+ `Docsmith::VersionManager` directly without any model mixin:
463
+
464
+ ```ruby
465
+ doc = Docsmith::Document.create!(
466
+ title: "My API Spec",
467
+ content: "# Version 1\n\nInitial spec.",
468
+ content_type: "markdown"
469
+ )
470
+
471
+ v1 = Docsmith::VersionManager.save!(doc, author: nil, summary: "Initial draft")
472
+ doc.update_column(:content, "# Version 1\n\nRevised spec.")
473
+ v2 = Docsmith::VersionManager.save!(doc, author: nil, summary: "Revised intro")
474
+
475
+ # Diff
476
+ result = Docsmith::Diff.between(v1, v2)
477
+ result.additions # => number of added tokens
478
+ result.to_html # => HTML diff markup
479
+
480
+ # Restore
481
+ Docsmith::VersionManager.restore!(doc, version: 1, author: nil)
482
+ doc.reload.content # => "# Version 1\n\nInitial spec."
483
+
484
+ # Tag
485
+ Docsmith::VersionManager.tag!(doc, version: 1, name: "golden", author: nil)
486
+ ```
487
+
488
+ ---
489
+
490
+ ## 15. Configuration Reference
491
+
492
+ | Key | Default | Description |
493
+ |-----|---------|-------------|
494
+ | `default_content_field` | `:body` | Column to snapshot when no per-class override |
495
+ | `default_content_type` | `:markdown` | Content type for new documents |
496
+ | `auto_save` | `true` | Enable after_save auto-snapshot |
497
+ | `default_debounce` | `30` | Seconds between auto-saves |
498
+ | `max_versions` | `nil` | Max snapshots per document; `nil` = unlimited |
499
+ | `content_extractor` | `nil` | Global proc overriding `content_field` |
500
+ | `table_prefix` | `"docsmith"` | Table name prefix |
501
+ | `diff_context_lines` | `3` | Context lines in diff output |
502
+
503
+ **Error classes:**
504
+
505
+ | Class | Raised when |
506
+ |-------|-------------|
507
+ | `Docsmith::InvalidContentField` | `content_field` returns a non-String |
508
+ | `Docsmith::VersionNotFound` | Requested version number does not exist |
509
+ | `Docsmith::TagAlreadyExists` | Tag name already used on this document |
510
+ | `Docsmith::MaxVersionsExceeded` | All versions are tagged and pruning is blocked |