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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +260 -2
- data/CODE_OF_CONDUCT.md +6 -127
- data/README.md +35 -1
- data/USAGE.md +195 -22
- data/lib/docsmith/comments/anchor.rb +6 -1
- data/lib/docsmith/comments/comment.rb +9 -13
- data/lib/docsmith/configuration.rb +19 -2
- data/lib/docsmith/diff/parsers/html.rb +12 -45
- data/lib/docsmith/diff/parsers/markdown.rb +13 -40
- data/lib/docsmith/diff/renderers/base.rb +134 -34
- data/lib/docsmith/diff/result.rb +74 -29
- data/lib/docsmith/document_version.rb +15 -0
- data/lib/docsmith/errors.rb +10 -0
- data/lib/docsmith/rendering/html_renderer.rb +26 -1
- data/lib/docsmith/rendering/json_renderer.rb +64 -17
- data/lib/docsmith/version.rb +7 -1
- data/lib/generators/docsmith/install/install_generator.rb +23 -1
- data/lib/generators/docsmith/install/templates/create_docsmith_tables.rb.erb +3 -3
- data/lib/generators/docsmith/install/templates/docsmith_initializer.rb.erb +13 -1
- metadata +17 -9
- data/.rspec +0 -3
- data/.rspec_status +0 -212
- data/docs/superpowers/plans/2026-04-01-docsmith-full-plan.md +0 -6459
- data/docs/superpowers/plans/2026-04-08-parsers-remove-branches-docs.md +0 -2112
- data/docs/superpowers/specs/2026-04-01-docsmith-phase1-design.md +0 -340
- data/docsmith_spec.md +0 -630
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) # =>
|
|
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.
|
|
270
|
-
result.deletions
|
|
271
|
-
result.
|
|
272
|
-
result.
|
|
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: :
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
#
|
|
307
|
-
#
|
|
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.
|
|
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
|
-
# "
|
|
329
|
-
# "
|
|
330
|
-
# "
|
|
331
|
-
# "
|
|
332
|
-
# "
|
|
333
|
-
#
|
|
334
|
-
#
|
|
335
|
-
# { "type" => "
|
|
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.
|
|
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
|
-
#
|
|
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
|
-
#
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
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
|
-
@
|
|
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
|
|
11
|
-
#
|
|
12
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
#
|
|
55
|
-
#
|
|
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
|
|
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
|
-
#
|
|
11
|
-
#
|
|
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
|
-
#
|
|
19
|
-
#
|
|
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
|
|
53
|
-
#
|
|
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
|
|
28
|
+
scan_with_offsets(content, WORD_OR_NEWLINES)
|
|
56
29
|
end
|
|
57
30
|
end
|
|
58
31
|
end
|