docsmith 0.1.0 → 0.2.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.
data/USAGE.md CHANGED
@@ -130,6 +130,35 @@ end
130
130
 
131
131
  Resolution order: **per-class `docsmith_config`** > **global `Docsmith.configure`** > **gem defaults**.
132
132
 
133
+ ### Rendering stored HTML safely — `html_sanitizer`
134
+
135
+ Stored HTML is untrusted input. If any of your `content_type: :html` documents
136
+ originated from a user, rendering them verbatim is **stored XSS**. Docsmith ships
137
+ no sanitizer of its own: sanitizing safely requires a real HTML parser, and
138
+ vendoring one would break the gem's zero-system-dependency guarantee.
139
+
140
+ So `render(:html)` escapes html content by default, and you opt into rendering it:
141
+
142
+ ```ruby
143
+ Docsmith.configure do |config|
144
+ # Default (html_sanitizer unset) — content is escaped inside
145
+ # <pre class="docsmith-html">. Safe, and visible enough that you notice it.
146
+
147
+ # Recommended. Rails already bundles this via ActionView, so no new gem:
148
+ config.html_sanitizer = ->(html) { Rails::HTML5::SafeListSanitizer.new.sanitize(html) }
149
+
150
+ # Verbatim passthrough. Only for HTML you generate yourself and fully trust:
151
+ config.html_sanitizer = :unsafe_raw
152
+ end
153
+ ```
154
+
155
+ Anything else — a String, a non-callable object — raises
156
+ `Docsmith::InvalidHtmlSanitizer` rather than quietly falling back to raw output.
157
+
158
+ This affects **only** `render(:html)` for `content_type: "html"`. Markdown and JSON
159
+ were always escaped, and diff output (`Diff::Result#to_html`) escapes every change
160
+ field regardless of content type.
161
+
133
162
  ---
134
163
 
135
164
  ## 6. Saving Versions
@@ -205,9 +234,63 @@ article.version(2).created_at # => 2026-03-01 14:22:00 UTC
205
234
 
206
235
  # Render a version's content
207
236
  article.version(2).render(:html) # => "<p>Body text at v2</p>"
208
- article.version(2).render(:json) # => '{"version":2,"content":"..."}'
237
+ article.version(2).render(:json) # => JSON envelope string, see below
238
+ article.version(2).export # => the same envelope as a Hash
239
+ ```
240
+
241
+ ### JSON export envelope
242
+
243
+ `render(:json)` returns a **single shape for every `content_type`**, so one parser
244
+ handles all of them:
245
+
246
+ ```ruby
247
+ JSON.parse(article.version(2).render(:json))
248
+ # => {
249
+ # "schema_version" => 1,
250
+ # "document_id" => 7,
251
+ # "version_number" => 2,
252
+ # "content_type" => "markdown",
253
+ # "content" => "# Hello\n\nSecond draft.",
254
+ # "change_summary" => "Second draft",
255
+ # "author" => { "type" => "User", "id" => 1 },
256
+ # "metadata" => {},
257
+ # "created_at" => "2026-03-01T14:22:00.000Z"
258
+ # }
259
+ ```
260
+
261
+ - **`content` is always the exact stored string**, byte for byte, for every content
262
+ type. Docsmith never reformats it: this is a versioning gem, and an export that
263
+ re-serialized the snapshot would not match what was versioned.
264
+ - **`author` is type and id only**, or `null`. The gem never serializes your author
265
+ record — it cannot know which of its fields are personal data.
266
+ - **`schema_version`** identifies the wire format. Branch on it rather than on the
267
+ gem version. It changes only when the payload shape changes.
268
+
269
+ To embed a version in a larger response without a JSON round-trip, use `#export`,
270
+ which returns the same Hash:
271
+
272
+ ```ruby
273
+ render json: { article: article.as_json, version: article.version(2).export }
274
+ ```
275
+
276
+ `DocumentVersion#as_json` is deliberately **not** overridden — `render json: @version`
277
+ still returns plain ActiveRecord attributes.
278
+
279
+ ### Parsed JSON documents — `include_parsed`
280
+
281
+ For `content_type: "json"` documents you can request the parsed document alongside
282
+ the raw string:
283
+
284
+ ```ruby
285
+ version.export(include_parsed: true)
286
+ # => { ..., "content" => '{"title":"Doc"}', "data" => { "title" => "Doc" } }
209
287
  ```
210
288
 
289
+ `content` still holds the exact bytes; `data` is the parsed form. On a non-json
290
+ document the option is a no-op. If the content does not parse, this raises
291
+ `Docsmith::InvalidJsonContent` — the **default export path never parses**, so it
292
+ cannot fail on malformed content.
293
+
211
294
  ---
212
295
 
213
296
  ## 9. Restoring Versions
@@ -266,12 +349,58 @@ document's `content_type`.
266
349
  result = article.diff_from(1)
267
350
  # => #<Docsmith::Diff::Result from_version: 1, to_version: 5, ...>
268
351
 
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
352
+ result.insertions # => count of PURE insertions
353
+ result.deletions # => count of PURE deletions
354
+ result.replacements # => count of spans replaced
355
+ result.stats # => all of the above plus "total"
356
+ result.changes # => grouped edits, see below
357
+ result.to_html # => HTML string with <ins>/<del> markup
358
+ result.to_json # => JSON string, see below
359
+ result.as_json # => the same payload as a Hash
360
+ ```
361
+
362
+ **`insertions` and `deletions` count only pure inserts and removals.** A changed
363
+ span counts once as a `replacement`, not as one of each. For git-style totals, add
364
+ `replacements` to either side:
365
+
366
+ ```ruby
367
+ git_style_insertions = result.insertions + result.replacements
368
+ ```
369
+
370
+ ### What a change looks like
371
+
372
+ Each entry in `changes` is a **contiguous run of edits collapsed into one**, with
373
+ genuine character offsets and line numbers on **both** sides:
374
+
375
+ ```ruby
376
+ # v1: "my document\n\nsecond para"
377
+ # v2: "<h1>this is a new heading</h1>\n\nsecond para"
378
+ result.changes
379
+ # => [
380
+ # { type: :replace,
381
+ # old: { start: 0, end: 11, line: 1, column: 1, text: "my document" },
382
+ # new: { start: 0, end: 30, line: 1, column: 1,
383
+ # text: "<h1>this is a new heading</h1>" } }
384
+ # ]
273
385
  ```
274
386
 
387
+ - **`type`** is `:insert`, `:delete`, or `:replace`.
388
+ - **Both sides are always present.** A pure insertion carries a zero-width `old`
389
+ span (`start == end`, empty text) marking *where* it was inserted; a pure deletion
390
+ carries a zero-width `new` span. Every entry therefore has identical keys, so you
391
+ never branch on type to know which fields exist.
392
+ - **`line` and `column` are real 1-indexed positions** in that document, and
393
+ `start`/`end` are character offsets (end exclusive). `content[start...end]` is
394
+ exactly `text`, so you can slice as much surrounding context as you want.
395
+ - **`old` and `new` never share a coordinate space** — `old` offsets index the older
396
+ document, `new` offsets the newer one.
397
+
398
+ > **Changed in 0.2.0.** Previously each *token* was its own entry, so a one-line
399
+ > heading rewrite produced seven of them. Worse, the `position.line` they carried
400
+ > was a **token index, not a line number** — it could exceed the document's line
401
+ > count — and it silently mixed coordinate systems, using an old-document index for
402
+ > deletions and a new-document index for additions.
403
+
275
404
  ### Diff between two named versions
276
405
 
277
406
  ```ruby
@@ -293,23 +422,33 @@ result = article.diff_between(2, 4)
293
422
  # v2 content: "The quick red fox"
294
423
  result = article.diff_between(1, 2)
295
424
  result.changes
296
- # => [{ type: :modification, line: 3, old_content: "brown", new_content: "red" }]
297
- result.additions # => 0
298
- result.deletions # => 0
425
+ # => [{ type: :replace,
426
+ # old: { start: 10, end: 15, line: 1, column: 11, text: "brown" },
427
+ # new: { start: 10, end: 13, line: 1, column: 11, text: "red" } }]
428
+ result.stats
429
+ # => { "insertions" => 0, "deletions" => 0, "replacements" => 1, "total" => 1 }
299
430
  ```
300
431
 
432
+ Words between two edits keep them separate — `"the quick brown fox"` to
433
+ `"the slow brown wolf"` yields **two** replaces, because `brown` is unchanged.
434
+
301
435
  **HTML example:**
302
436
 
303
437
  ```ruby
304
438
  # v1 content: "<p>Hello world</p>"
305
439
  # 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>"
440
+ # The four added tokens ("<p>", "New", "paragraph", "</p>") are one contiguous
441
+ # run, so they collapse into a single insert.
309
442
  result = article.diff_between(1, 2)
310
- result.additions # => 4
443
+ result.insertions # => 1
444
+ result.changes.first[:new][:text]
445
+ # => "<p>New paragraph</p>"
311
446
  ```
312
447
 
448
+ Note the tokenizer discards the whitespace *between* tokens, but an edit's `text`
449
+ is sliced from the source between its offsets — so spaces and newlines inside a
450
+ run are preserved verbatim.
451
+
313
452
  ### to_html output
314
453
 
315
454
  ```ruby
@@ -325,18 +464,53 @@ result.to_html
325
464
  ```ruby
326
465
  JSON.parse(result.to_json)
327
466
  # => {
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" }
467
+ # "schema_version" => 1,
468
+ # "content_type" => "markdown",
469
+ # "from_version" => 1,
470
+ # "to_version" => 3,
471
+ # "stats" => { "insertions" => 1, "deletions" => 0,
472
+ # "replacements" => 1, "total" => 2 },
473
+ # "changes" => [
474
+ # { "type" => "replace",
475
+ # "old" => { "start" => 0, "end" => 11, "line" => 1, "column" => 1,
476
+ # "text" => "my document" },
477
+ # "new" => { "start" => 0, "end" => 30, "line" => 1, "column" => 1,
478
+ # "text" => "<h1>this is a new heading</h1>" } },
479
+ # { "type" => "insert",
480
+ # "old" => { "start" => 24, "end" => 24, "line" => 3, "column" => 13,
481
+ # "text" => "" },
482
+ # "new" => { "start" => 43, "end" => 52, "line" => 3, "column" => 13,
483
+ # "text" => " appended" } }
336
484
  # ]
337
485
  # }
338
486
  ```
339
487
 
488
+ ### Nesting a diff in a larger response
489
+
490
+ `Diff::Result` implements `as_json`, so it serializes identically whether it is
491
+ encoded on its own or nested anywhere inside another structure:
492
+
493
+ ```ruby
494
+ render json: { diff: result } # correct payload under "diff"
495
+ render json: { diffs: [result, other] } # correct payload in each element
496
+ JSON.generate(result) # same as result.to_json
497
+ ```
498
+
499
+ > **Fixed in 0.2.0.** `as_json` was previously undefined, so nesting fell through
500
+ > to `Object#as_json` and produced a **different** payload from `to_json` — with no
501
+ > `stats` key at all, and the raw internal change hashes in place of the documented
502
+ > ones. If you worked around this by calling `JSON.parse(result.to_json)` before
503
+ > nesting, you can now pass the result directly.
504
+
505
+ `result.changes` is the same structure symbol-keyed, so there is exactly one model
506
+ of what a change is:
507
+
508
+ ```ruby
509
+ { type: :replace,
510
+ old: { start: 0, end: 11, line: 1, column: 1, text: "my document" },
511
+ new: { start: 0, end: 30, line: 1, column: 1, text: "<h1>..." } }
512
+ ```
513
+
340
514
  ---
341
515
 
342
516
  ## 12. Comments
@@ -474,7 +648,7 @@ v2 = Docsmith::VersionManager.save!(doc, author: nil, summary: "Revised intro")
474
648
 
475
649
  # Diff
476
650
  result = Docsmith::Diff.between(v1, v2)
477
- result.additions # => number of added tokens
651
+ result.insertions # => number of pure insertions
478
652
  result.to_html # => HTML diff markup
479
653
 
480
654
  # Restore
@@ -498,7 +672,6 @@ Docsmith::VersionManager.tag!(doc, version: 1, name: "golden", author: nil)
498
672
  | `max_versions` | `nil` | Max snapshots per document; `nil` = unlimited |
499
673
  | `content_extractor` | `nil` | Global proc overriding `content_field` |
500
674
  | `table_prefix` | `"docsmith"` | Table name prefix |
501
- | `diff_context_lines` | `3` | Context lines in diff output |
502
675
 
503
676
  **Error classes:**
504
677
 
@@ -45,8 +45,13 @@ module Docsmith
45
45
  original_hash = anchor_data["content_hash"]
46
46
  original_text = anchor_data["anchored_text"]
47
47
 
48
+ # An anchor missing its offsets or its captured text cannot be relocated.
49
+ # Previously this reached content[nil...nil] / content.index(nil) and
50
+ # raised TypeError out of a migration loop.
51
+ return anchor_data.merge("status" => ORPHANED) if original_text.nil? || original_text.empty?
52
+
48
53
  # 1. Exact offset check
49
- candidate = content[start_off...end_off].to_s
54
+ candidate = start_off.nil? || end_off.nil? ? "" : content[start_off...end_off].to_s
50
55
  return anchor_data.merge("status" => ACTIVE) if Digest::SHA256.hexdigest(candidate) == original_hash
51
56
 
52
57
  # 2. Full-text search for relocated text
@@ -25,20 +25,16 @@ module Docsmith
25
25
  scope :document_level, -> { where(anchor_type: "document") }
26
26
  scope :range_anchored, -> { where(anchor_type: "range") }
27
27
 
28
- # Deserializes anchor_data from JSON text (SQLite) or returns hash directly (PostgreSQL jsonb).
28
+ # anchor_data needs no custom accessors: the column is :json everywhere
29
+ # (:jsonb on PostgreSQL), so ActiveRecord casts it to a Hash on read and
30
+ # serializes it on write. The previous hand-rolled pair existed only
31
+ # because the test schema declared the column :text, and it raised an
32
+ # unrescued JSON::ParserError from a plain attribute reader when the
33
+ # stored text was malformed.
29
34
  #
30
- # @return [Hash]
31
- def anchor_data
32
- raw = read_attribute(:anchor_data)
33
- raw.is_a?(String) ? JSON.parse(raw) : raw.to_h
34
- end
35
-
36
- # Serializes anchor_data as JSON for storage.
37
- #
38
- # @param data [Hash, String]
39
- def anchor_data=(data)
40
- write_attribute(:anchor_data, data.is_a?(String) ? data : data.to_json)
41
- end
35
+ # Assign a Hash, not a JSON String. A String is stored as a JSON string
36
+ # literal and reads back as a String, where the old :text column would
37
+ # have parsed it into a Hash.
42
38
  end
43
39
  end
44
40
  end
@@ -44,7 +44,24 @@ module Docsmith
44
44
 
45
45
  attr_accessor :default_content_field, :default_content_type, :auto_save,
46
46
  :default_debounce, :max_versions, :content_extractor,
47
- :table_prefix, :diff_context_lines
47
+ :table_prefix
48
+
49
+ # Controls how HtmlRenderer emits stored content for html documents.
50
+ #
51
+ # Stored HTML is untrusted: rendering it verbatim is stored XSS whenever the
52
+ # content originated from a user. Docsmith ships no sanitizer of its own —
53
+ # safe sanitizing needs a real HTML parser, and vendoring one would break the
54
+ # gem's zero-system-dependency guarantee.
55
+ #
56
+ # nil (default) — escape the content. Safe, and visibly wrong, so it gets noticed.
57
+ # #call(html) — your sanitizer. Rails apps already have one via ActionView:
58
+ # config.html_sanitizer = ->(html) {
59
+ # Rails::HTML5::SafeListSanitizer.new.sanitize(html)
60
+ # }
61
+ # :unsafe_raw — verbatim passthrough. Only for content you produce yourself.
62
+ #
63
+ # @return [nil, #call, :unsafe_raw]
64
+ attr_accessor :html_sanitizer
48
65
 
49
66
  def initialize
50
67
  @default_content_field = DEFAULTS[:content_field]
@@ -54,7 +71,7 @@ module Docsmith
54
71
  @max_versions = DEFAULTS[:max_versions]
55
72
  @content_extractor = DEFAULTS[:content_extractor]
56
73
  @table_prefix = "docsmith"
57
- @diff_context_lines = 3
74
+ @html_sanitizer = nil
58
75
  @hooks = Hash.new { |h, k| h[k] = [] }
59
76
  end
60
77
 
@@ -7,56 +7,23 @@ module Docsmith
7
7
  module Parsers
8
8
  # HTML-aware diff parser for HTML documents.
9
9
  #
10
- # Tokenizes HTML so that each tag (including its attributes) is one atomic
11
- # unit and text words are separate units. This prevents the diff engine from
12
- # splitting `<p class="foo">` into angle brackets, attribute names, and values.
10
+ # Tokenizes so each tag (with its attributes) is one atomic unit and text
11
+ # words are separate units. This keeps the diff engine from splitting
12
+ # `<p class="foo">` into angle brackets, attribute names, and values.
13
13
  #
14
- # Tokenization regex: /<[^>]+>|[^\s<>]+/
15
- # - /<[^>]+>/ matches any HTML tag: <p>, </p>, <div class="x">, <br/>
16
- # - /[^\s<>]+/ matches words in text content between tags
14
+ # "<p>Hello world</p>" ["<p>", "Hello", "world", "</p>"]
17
15
  #
18
- # Example: "<p>Hello world</p>" ["<p>", "Hello", "world", "</p>"]
19
- #
20
- # The :line key in change hashes stores the 1-indexed token position
21
- # (not a line number) for compatibility with Diff::Result serialization.
16
+ # Grouping, offsets, and rendering all come from Renderers::Base — this
17
+ # class only decides where token boundaries fall.
22
18
  class Html < Renderers::Base
23
- TAG_OR_WORD = /<[^>]+>|[^\s<>]+/.freeze
24
-
25
- # @param old_content [String]
26
- # @param new_content [String]
27
- # @return [Array<Hash>] change hashes with :type, :line (token index), and content keys
28
- def compute(old_content, new_content)
29
- old_tokens = tokenize(old_content)
30
- new_tokens = tokenize(new_content)
31
- changes = []
32
-
33
- ::Diff::LCS.sdiff(old_tokens, new_tokens).each do |hunk|
34
- case hunk.action
35
- when "+"
36
- changes << { type: :addition, line: hunk.new_position + 1, content: hunk.new_element.to_s }
37
- when "-"
38
- changes << { type: :deletion, line: hunk.old_position + 1, content: hunk.old_element.to_s }
39
- when "!"
40
- changes << {
41
- type: :modification,
42
- line: hunk.old_position + 1,
43
- old_content: hunk.old_element.to_s,
44
- new_content: hunk.new_element.to_s
45
- }
46
- end
47
- end
48
-
49
- changes
50
- end
51
-
52
- private
19
+ # /<[^>]+>/ any tag: <p>, </p>, <div class="x">, <br/>
20
+ # /[^\s<>]+/ words in text content between tags
21
+ TAG_OR_WORD = /<[^>]+>|[^\s<>]+/
53
22
 
54
- # Splits HTML into tokens:
55
- # - Each HTML tag (including attributes) is one token
56
- # - Each word in text content is one token
57
- # Whitespace between tokens is discarded.
23
+ # @param content [String]
24
+ # @return [Array<Array(String, Integer)>] [token, start_offset] pairs
58
25
  def tokenize(content)
59
- content.scan(TAG_OR_WORD)
26
+ scan_with_offsets(content, TAG_OR_WORD)
60
27
  end
61
28
  end
62
29
  end
@@ -7,52 +7,25 @@ module Docsmith
7
7
  module Parsers
8
8
  # Word-level diff parser for Markdown documents.
9
9
  #
10
- # Instead of comparing line-by-line (as Renderers::Base does), this parser
11
- # tokenizes content into individual words and newline groups, then diffs
12
- # those tokens. This gives precise word-level change detection for prose,
13
- # which is far more useful than "the whole line changed."
10
+ # Tokenizes into words and newline runs rather than lines, so prose edits
11
+ # are detected at word granularity instead of "the whole line changed".
14
12
  #
15
- # Tokenization: content.scan(/\S+|\n+/)
16
13
  # "Hello world\n\nFoo" → ["Hello", "world", "\n\n", "Foo"]
17
14
  #
18
- # The :line key in change hashes stores the 1-indexed token position
19
- # (not a line number) for compatibility with Diff::Result serialization.
15
+ # Grouping, offsets, and rendering all come from Renderers::Base this
16
+ # class only decides where token boundaries fall. Spaces and tabs between
17
+ # tokens are not themselves tokens, but they are still present in an edit's
18
+ # `text`, which is sliced from the source between the edit's offsets.
20
19
  class Markdown < Renderers::Base
21
- # @param old_content [String]
22
- # @param new_content [String]
23
- # @return [Array<Hash>] change hashes with :type, :line (token index), and content keys
24
- def compute(old_content, new_content)
25
- old_tokens = tokenize(old_content)
26
- new_tokens = tokenize(new_content)
27
- changes = []
28
-
29
- ::Diff::LCS.sdiff(old_tokens, new_tokens).each do |hunk|
30
- case hunk.action
31
- when "+"
32
- changes << { type: :addition, line: hunk.new_position + 1, content: hunk.new_element.to_s }
33
- when "-"
34
- changes << { type: :deletion, line: hunk.old_position + 1, content: hunk.old_element.to_s }
35
- when "!"
36
- changes << {
37
- type: :modification,
38
- line: hunk.old_position + 1,
39
- old_content: hunk.old_element.to_s,
40
- new_content: hunk.new_element.to_s
41
- }
42
- end
43
- end
44
-
45
- changes
46
- end
47
-
48
- private
49
-
50
- # Splits markdown into word tokens.
51
20
  # \S+ matches any non-whitespace run (words, punctuation, markdown markers).
52
- # \n+ matches one or more consecutive newlines as a single token so that
53
- # paragraph breaks (\n\n) and line breaks (\n) are each one diffable unit.
21
+ # \n+ matches consecutive newlines as one token, so a paragraph break
22
+ # (\n\n) and a line break (\n) are each a single diffable unit.
23
+ WORD_OR_NEWLINES = /\S+|\n+/
24
+
25
+ # @param content [String]
26
+ # @return [Array<Array(String, Integer)>] [token, start_offset] pairs
54
27
  def tokenize(content)
55
- content.scan(/\S+|\n+/)
28
+ scan_with_offsets(content, WORD_OR_NEWLINES)
56
29
  end
57
30
  end
58
31
  end