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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2ac73e1aab94e9ac08a563be17f38f86c7bc3c054b1e160de5853f954f9265db
4
- data.tar.gz: eab3d09587f2d63754f5b6f2d7a2e8cee767815797ac6a6412416230f97dd8b3
3
+ metadata.gz: 41620e26ac475ea46a8611b5aab7e808bff7a67e0246f9af0894c59164dd88b2
4
+ data.tar.gz: 89bb6a2920c4088f72de8b6c6c51d8e7aaffa6547fbaba815ef6f06d14e04ddd
5
5
  SHA512:
6
- metadata.gz: dc5e70e6755c883572dd43c5ccbc66477da572d20c4bd280d128c5d0f899af92c29ecc6635fa57b1cd607ea49a1deeb6a0567909c8c250c34147e8399e359c45
7
- data.tar.gz: 826aaf0e992b57f172f128c05651256d72ca18fa3eb93bfa2df6c9d9073ccc79187e3264a2dae53f60321ae5e8b7bbac113615f52cbb1ef8ee96d1ab23b8de9a
6
+ metadata.gz: 4670c62789e9c5555dbfdf514aeb9df24e87c422d85d9dd51e1be7270724890151a316595675dccb59b670484d71ac25f5214f09ff7d677e1b7481a7a71eabce
7
+ data.tar.gz: f7d76c3a04ab77535e9f047dc56b6c05420d2200800c9291fb568f82db99fc752386432c7e2a68a377d4dfa4580ea99ba4da228ce78956f2063c9d403453284f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,263 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented here.
4
+
1
5
  ## [Unreleased]
2
6
 
3
- ## [0.1.0] - 2026-03-31
7
+ ## [0.2.0] - 2026-07-26
8
+
9
+ ### Added
10
+
11
+ - **`Diff::Result#as_json`** — the canonical diff payload, and now the single
12
+ source of truth for it. `to_json` delegates to it.
13
+ - **`Diff::Result#modifications` and `#stats`** — explicit change counts.
14
+ - **`Rendering::JsonRenderer#to_h`** — the canonical content envelope; `#render`
15
+ is `to_h(...).to_json`.
16
+ - **`DocumentVersion#export(**options)`** — the envelope as a Hash, for embedding
17
+ a version in a larger API response without a JSON round-trip. `as_json` is
18
+ deliberately NOT overridden on the model, so `render json: @version` still
19
+ returns plain ActiveRecord attributes.
20
+ - **`schema_version`** on both envelopes, sourced from
21
+ `Docsmith::JSON_SCHEMA_VERSION`. Clients should branch on this rather than on
22
+ the gem version.
23
+ - **`include_parsed: true`** on the content export, adding a `data` key with the
24
+ parsed document for `content_type: "json"`. No-op for other content types.
25
+ - `Docsmith::InvalidJsonContent`, raised when `include_parsed` is set and the
26
+ content does not parse.
27
+ - `config.html_sanitizer` — controls how `render(:html)` treats stored html
28
+ content. Accepts `nil` (default: escape), any object responding to `#call`, or
29
+ `:unsafe_raw`. See the Security note below.
30
+ - `Docsmith::InvalidHtmlSanitizer` error, raised when `html_sanitizer` is set to
31
+ something that is neither callable, `:unsafe_raw`, nor `nil`.
32
+
33
+ ### Changed
34
+
35
+ - **Both JSON exports are a new, single, documented shape.** This is a breaking
36
+ wire-format change with no compatibility flag.
37
+
38
+ **`Diff::Result` — nesting no longer produces a different payload.** `as_json`
39
+ was undefined, so nesting fell through to `Object#as_json` (`instance_values`):
40
+
41
+ ```jsonc
42
+ // BEFORE — result.to_json (documented)
43
+ { "content_type": "markdown", "from_version": 1, "to_version": 3,
44
+ "stats": { "additions": 2, "deletions": 0 },
45
+ "changes": [ { "type": "modification", "position": { "line": 4 }, … } ] }
46
+
47
+ // BEFORE — { diff: result }.to_json, i.e. `render json: { diff: result }`
48
+ // no "stats" at all, and "line" instead of "position"
49
+ { "diff": { "content_type": "markdown", "from_version": 1, "to_version": 3,
50
+ "changes": [ { "type": "modification", "line": 4, … } ] } }
51
+
52
+ // AFTER — identical either way, plus schema_version and full stats
53
+ { "schema_version": 1, "content_type": "markdown",
54
+ "from_version": 1, "to_version": 3,
55
+ "stats": { "additions": 2, "deletions": 0, "modifications": 1, "total": 3 },
56
+ "changes": [ { "type": "modification", "position": { "line": 4 }, … } ] }
57
+ ```
58
+
59
+ **`render(:json)` — one envelope instead of two schemas.** It previously returned
60
+ the bare pretty-printed document for `content_type: "json"` and an envelope for
61
+ everything else, so no client could parse it generically:
62
+
63
+ ```jsonc
64
+ // BEFORE — content_type "json": no envelope, reformatted
65
+ { "title": "Doc", "tags": [ "a" ] }
66
+
67
+ // BEFORE — any other content_type: envelope, two keys
68
+ { "content_type": "markdown", "content": "# Hello" }
69
+
70
+ // AFTER — one shape for every content_type
71
+ { "schema_version": 1, "document_id": 7, "version_number": 2,
72
+ "content_type": "markdown", "content": "# Hello\n\nSecond draft.",
73
+ "change_summary": "Second draft",
74
+ "author": { "type": "User", "id": 1 },
75
+ "metadata": {}, "created_at": "2026-03-01T14:22:00.000Z" }
76
+ ```
77
+
78
+ Also note: `content` is now **always the exact stored string**, byte for byte.
79
+ JSON documents are no longer pretty-printed on export, so an export matches what
80
+ was versioned. And `USAGE.md` previously documented a `"version"` key that the
81
+ implementation never emitted; the envelope now carries `version_number`.
82
+
83
+ - **`changes` is redesigned: grouped edits with real positions.** The old array had
84
+ no usable semantics. Each *token* was its own entry, so a one-line heading
85
+ rewrite produced seven of them. The `position.line` they carried was a **token
86
+ index, not a line number** — it could exceed the document's line count — and it
87
+ silently mixed coordinate systems, using an old-document index for deletions and
88
+ a new-document index for additions, so the values were not even comparable to
89
+ each other.
90
+
91
+ ```jsonc
92
+ // BEFORE — "my document" -> "<h1>this is a new heading</h1>"
93
+ // in a 3-line document. Seven entries; "line" reaches 7.
94
+ "changes": [
95
+ { "type": "modification", "position": { "line": 1 },
96
+ "old_content": "my", "new_content": "<h1>" },
97
+ { "type": "modification", "position": { "line": 2 },
98
+ "old_content": "document", "new_content": "this" },
99
+ { "type": "addition", "position": { "line": 3 }, "content": "is" },
100
+ { "type": "addition", "position": { "line": 4 }, "content": "a" },
101
+ { "type": "addition", "position": { "line": 5 }, "content": "new" },
102
+ { "type": "addition", "position": { "line": 6 }, "content": "heading" },
103
+ { "type": "addition", "position": { "line": 7 }, "content": "</h1>" }
104
+ ]
105
+
106
+ // AFTER — one entry, real offsets and line numbers on both sides
107
+ "changes": [
108
+ { "type": "replace",
109
+ "old": { "start": 0, "end": 11, "line": 1, "column": 1,
110
+ "text": "my document" },
111
+ "new": { "start": 0, "end": 30, "line": 1, "column": 1,
112
+ "text": "<h1>this is a new heading</h1>" } }
113
+ ]
114
+ ```
115
+
116
+ - A contiguous run of edits collapses into **one** entry. A pure-insert run is
117
+ `insert`, pure-delete is `delete`, mixed is `replace`.
118
+ - **Both sides are always present**, so every entry has identical keys. A pure
119
+ insertion carries a zero-width `old` span marking the insertion point, which
120
+ also makes the payload applyable as a patch.
121
+ - `start`/`end` are character offsets (end exclusive); `line`/`column` are real
122
+ 1-indexed positions. `content[start...end] == text`, so clients can slice
123
+ their own context.
124
+ - `old` and `new` never share a coordinate space.
125
+ - `Result#changes` returns the same structure symbol-keyed, so Ruby and JSON
126
+ agree on what a change is. `Renderers::Base#render_html` consumes it.
127
+
128
+ - **Edit types and `stats` keys use standard diff vocabulary.** Types are now
129
+ `insert` / `delete` / `replace` (was `addition` / `deletion` / `modification`),
130
+ and `stats` is `insertions` / `deletions` / `replacements` / `total`. Ruby readers
131
+ match: `#insertions`, `#deletions`, `#replacements`. Previously a diff whose every
132
+ line changed reported `additions: 0, deletions: 0` alongside a non-empty
133
+ `changes` array; a replacement is now counted once, and for git-style totals you
134
+ add `replacements` to either side.
135
+
136
+ - The format-aware parsers were reduced to their tokenizer. `Parsers::Markdown` and
137
+ `Parsers::Html` had near-identical copies of `compute`; grouping, offsets, and
138
+ rendering now live once in `Renderers::Base` and subclasses override `tokenize`
139
+ only. A custom renderer registered via `Renderers::Registry` should do the same.
140
+
141
+ - **`Comments::Comment#anchor_data` takes a Hash.** The hand-rolled accessor pair
142
+ that existed only to paper over the `:text` column is gone. Passing a
143
+ pre-serialized JSON String now stores it as a JSON string literal and reads
144
+ back as a String, where the old `:text` column parsed it into a Hash. Callers
145
+ doing `anchor_data: data.to_json` should pass `anchor_data: data`.
146
+
147
+ ### Security
148
+
149
+ - **Fixed stored XSS in `version.render(:html)` for `content_type: "html"`.** The
150
+ renderer returned stored content verbatim, so any HTML that originated from a
151
+ user executed in the browser. html content is now escaped by default and
152
+ rendered only through an explicitly configured sanitizer.
153
+
154
+ Docsmith deliberately ships no sanitizer — a safe one requires a real HTML
155
+ parser, and adding Nokogiri would break the zero-system-dependency guarantee.
156
+
157
+ **This is a breaking change for anyone rendering html documents.** To restore
158
+ rendering, pick one:
159
+
160
+ ```ruby
161
+ # Recommended — Rails already bundles this via ActionView, so no new gem:
162
+ config.html_sanitizer = ->(html) { Rails::HTML5::SafeListSanitizer.new.sanitize(html) }
163
+
164
+ # Verbatim passthrough, i.e. the old behavior. Only for HTML you fully trust:
165
+ config.html_sanitizer = :unsafe_raw
166
+ ```
167
+
168
+ A non-callable, non-`:unsafe_raw` value raises `Docsmith::InvalidHtmlSanitizer`
169
+ instead of degrading to raw output. Markdown and JSON rendering are unaffected,
170
+ and diff output was already escaped for every content type.
171
+
172
+ ### Fixed
173
+
174
+ - **`rails generate docsmith:install` never worked.** The generator's action method
175
+ was named `create_migration`, which shadows `Rails::Generators::Migration`'s own
176
+ `create_migration(destination, data, config)` — the method `migration_template`
177
+ calls internally. Running the generator died with `ArgumentError: wrong number of
178
+ arguments (given 3, expected 0)` before writing a single file, so the documented
179
+ first step of installing the gem failed outright in 0.1.0. Renamed to
180
+ `create_migration_file`.
181
+
182
+ This went unnoticed because the generator had no test coverage: `railties`
183
+ provides `rails/generators` and was not a development dependency, so the spec
184
+ suite could not load the generator at all. `railties` is now a **development**
185
+ dependency — deliberately not a runtime one, since docsmith targets plain
186
+ ActiveRecord and nothing in `lib/docsmith.rb` loads Rails. The generator now has
187
+ specs that run it against a temp directory and execute the migration it produces.
188
+ - JSON payload columns (`metadata`, `anchor_data`) are now `:json` rather than
189
+ `:text` in the gem's own schemas. They previously read back as a `Hash` on
190
+ PostgreSQL and as the literal String `"{}"` on SQLite and MySQL, so models saw
191
+ a different type in tests than in production.
192
+ - `rails generate docsmith:install` no longer emits a PostgreSQL-only migration.
193
+ It picks `jsonb` for PostgreSQL-derived adapters and `json` elsewhere, so the
194
+ generated migration runs on MySQL and SQLite instead of failing on `t.jsonb`.
195
+ - `Comments::Anchor.migrate` no longer raises `TypeError` out of the middle of a
196
+ comment migration when an anchor is missing its `anchored_text` or offsets.
197
+ Such anchors are now reported as `orphaned`.
198
+ - `Comments::Comment#anchor_data` no longer raises an unrescued
199
+ `JSON::ParserError` from a plain attribute reader on malformed stored data.
200
+
201
+ ### Demo
202
+
203
+ - Added an **HTML & Sanitizer** section (`/pages`) built on a `content_type :html`
204
+ model, showing `render(:html)` output under each `html_sanitizer` mode against
205
+ content containing a `<script>` tag. Seeded automatically.
206
+ - **Fixed stored XSS in the demo's own views.** Sinatra's stock ERB does not
207
+ escape `<%= %>`, so an article title, comment body, or tag name containing a
208
+ `<script>` tag executed in the browser. The route classes now set
209
+ `erb, escape_html: true`, and the two places that intentionally emit HTML
210
+ (`layout.erb`'s `yield`, `diff.erb`'s `to_html`) use `<%== %>`.
211
+ - `demo/db/setup.rb` migrates pre-existing demo databases from the old `:text`
212
+ columns to `:json` via `change_column`, preserving all rows. The `create_table`
213
+ calls are guarded by `table_exists?`, so without this an existing demo DB would
214
+ have silently kept its old column types.
215
+
216
+ ### Upgrading
217
+
218
+ - **No migration is required for existing installs.** The column-type fix affects
219
+ the schemas Docsmith ships for its own tests and demo; installs generated
220
+ before this release already have `jsonb` columns, which behave identically.
221
+ The generator change only matters for new installs on MySQL and SQLite, where
222
+ the old `t.jsonb` migration could not run at all.
223
+ - If you render `content_type: "html"` documents, set `config.html_sanitizer`
224
+ before upgrading, or that content will render escaped.
225
+ - If you pass `anchor_data:` a pre-serialized JSON String anywhere, change it to
226
+ pass the Hash directly.
227
+ - **Update any consumer of the two JSON exports.** Both shapes changed and there is
228
+ no compatibility flag. Branch on `schema_version` going forward.
229
+ - If you parsed `render(:json)` for a `content_type: "json"` document, the
230
+ document is no longer the payload root — read `content` (a string) or pass
231
+ `include_parsed: true` and read `data`.
232
+ - If you nested a `Diff::Result` and worked around the missing `stats` by calling
233
+ `JSON.parse(result.to_json)` first, you can now pass the result directly.
234
+ - If you read `additions`/`deletions` as an overall change count, read
235
+ `stats["total"]`, or add `replacements` to either side.
236
+ - Rename `result.additions` to `result.insertions` and `result.modifications` to
237
+ `result.replacements`. `result.deletions` keeps its name.
238
+ - If you walked `result.changes`, it is now grouped edits with `old`/`new` spans
239
+ rather than a flat token list. Anything reading `c[:line]` or `c[:content]`
240
+ needs updating — and note that `c[:line]` was never a line number.
241
+ - If you subclassed a parser or registered a custom diff renderer, override
242
+ `#tokenize` (returning `[text, start_offset]` pairs) instead of `#compute`.
243
+ - **`config.diff_context_lines` is removed.** It was declared and documented but
244
+ read by nothing, so it never had any effect. With real `start`/`end` offsets on
245
+ both sides of every edit, a client can slice whatever context it needs from the
246
+ document text.
247
+
248
+ ## [0.1.0] - 2026-04-12
249
+
250
+ First public release.
251
+
252
+ ### Added
4
253
 
5
- - Initial release
254
+ - `Docsmith::Versionable` mixin — include in any ActiveRecord model to enable versioning
255
+ - Snapshot-based versioning: full content snapshots for HTML, Markdown, and JSON with instant rollback to any version
256
+ - Format-aware diff engine: word-level diffs for Markdown, tag-atomic diffs for HTML
257
+ - Inline and document-level comments with threading, resolution, and version migration
258
+ - Debounced auto-save with configurable threshold per class
259
+ - Per-class and global configuration via `docsmith_config` block
260
+ - Lifecycle events: `version_created`, `version_restored`, `version_tagged`
261
+ - Standalone `Docsmith::Document` service API — works without a model mixin
262
+ - `rails generate docsmith:install` generator with migration
263
+ - Zero system dependencies — pure Ruby on top of ActiveRecord and diff-lcs
data/CODE_OF_CONDUCT.md CHANGED
@@ -1,132 +1,11 @@
1
- # Contributor Covenant Code of Conduct
1
+ # Code of Conduct
2
2
 
3
- ## Our Pledge
3
+ This is a small open source project, maintained by one person. If you contribute, a few things I ask:
4
4
 
5
- We as members, contributors, and leaders pledge to make participation in our
6
- community a harassment-free experience for everyone, regardless of age, body
7
- size, visible or invisible disability, ethnicity, sex characteristics, gender
8
- identity and expression, level of experience, education, socio-economic status,
9
- nationality, personal appearance, race, caste, color, religion, or sexual
10
- identity and orientation.
5
+ **Be straight.** If something is broken, say so. If you disagree with a design decision, explain why. Honest feedback is welcome rudeness isn't.
11
6
 
12
- We pledge to act and interact in ways that contribute to an open, welcoming,
13
- diverse, inclusive, and healthy community.
7
+ **Be patient.** I review issues and PRs in my own time. If something takes a while, a polite nudge is fine.
14
8
 
15
- ## Our Standards
9
+ **Be constructive.** Bug reports with reproducible examples, PRs with context, questions that show you tried first — these make the project better for everyone.
16
10
 
17
- Examples of behavior that contributes to a positive environment for our
18
- community include:
19
-
20
- * Demonstrating empathy and kindness toward other people
21
- * Being respectful of differing opinions, viewpoints, and experiences
22
- * Giving and gracefully accepting constructive feedback
23
- * Accepting responsibility and apologizing to those affected by our mistakes,
24
- and learning from the experience
25
- * Focusing on what is best not just for us as individuals, but for the overall
26
- community
27
-
28
- Examples of unacceptable behavior include:
29
-
30
- * The use of sexualized language or imagery, and sexual attention or advances of
31
- any kind
32
- * Trolling, insulting or derogatory comments, and personal or political attacks
33
- * Public or private harassment
34
- * Publishing others' private information, such as a physical or email address,
35
- without their explicit permission
36
- * Other conduct which could reasonably be considered inappropriate in a
37
- professional setting
38
-
39
- ## Enforcement Responsibilities
40
-
41
- Community leaders are responsible for clarifying and enforcing our standards of
42
- acceptable behavior and will take appropriate and fair corrective action in
43
- response to any behavior that they deem inappropriate, threatening, offensive,
44
- or harmful.
45
-
46
- Community leaders have the right and responsibility to remove, edit, or reject
47
- comments, commits, code, wiki edits, issues, and other contributions that are
48
- not aligned to this Code of Conduct, and will communicate reasons for moderation
49
- decisions when appropriate.
50
-
51
- ## Scope
52
-
53
- This Code of Conduct applies within all community spaces, and also applies when
54
- an individual is officially representing the community in public spaces.
55
- Examples of representing our community include using an official email address,
56
- posting via an official social media account, or acting as an appointed
57
- representative at an online or offline event.
58
-
59
- ## Enforcement
60
-
61
- Instances of abusive, harassing, or otherwise unacceptable behavior may be
62
- reported to the community leaders responsible for enforcement at
63
- [INSERT CONTACT METHOD].
64
- All complaints will be reviewed and investigated promptly and fairly.
65
-
66
- All community leaders are obligated to respect the privacy and security of the
67
- reporter of any incident.
68
-
69
- ## Enforcement Guidelines
70
-
71
- Community leaders will follow these Community Impact Guidelines in determining
72
- the consequences for any action they deem in violation of this Code of Conduct:
73
-
74
- ### 1. Correction
75
-
76
- **Community Impact**: Use of inappropriate language or other behavior deemed
77
- unprofessional or unwelcome in the community.
78
-
79
- **Consequence**: A private, written warning from community leaders, providing
80
- clarity around the nature of the violation and an explanation of why the
81
- behavior was inappropriate. A public apology may be requested.
82
-
83
- ### 2. Warning
84
-
85
- **Community Impact**: A violation through a single incident or series of
86
- actions.
87
-
88
- **Consequence**: A warning with consequences for continued behavior. No
89
- interaction with the people involved, including unsolicited interaction with
90
- those enforcing the Code of Conduct, for a specified period of time. This
91
- includes avoiding interactions in community spaces as well as external channels
92
- like social media. Violating these terms may lead to a temporary or permanent
93
- ban.
94
-
95
- ### 3. Temporary Ban
96
-
97
- **Community Impact**: A serious violation of community standards, including
98
- sustained inappropriate behavior.
99
-
100
- **Consequence**: A temporary ban from any sort of interaction or public
101
- communication with the community for a specified period of time. No public or
102
- private interaction with the people involved, including unsolicited interaction
103
- with those enforcing the Code of Conduct, is allowed during this period.
104
- Violating these terms may lead to a permanent ban.
105
-
106
- ### 4. Permanent Ban
107
-
108
- **Community Impact**: Demonstrating a pattern of violation of community
109
- standards, including sustained inappropriate behavior, harassment of an
110
- individual, or aggression toward or disparagement of classes of individuals.
111
-
112
- **Consequence**: A permanent ban from any sort of public interaction within the
113
- community.
114
-
115
- ## Attribution
116
-
117
- This Code of Conduct is adapted from the [Contributor Covenant][homepage],
118
- version 2.1, available at
119
- [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
120
-
121
- Community Impact Guidelines were inspired by
122
- [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
123
-
124
- For answers to common questions about this code of conduct, see the FAQ at
125
- [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
126
- [https://www.contributor-covenant.org/translations][translations].
127
-
128
- [homepage]: https://www.contributor-covenant.org
129
- [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
130
- [Mozilla CoC]: https://github.com/mozilla/diversity
131
- [FAQ]: https://www.contributor-covenant.org/faq
132
- [translations]: https://www.contributor-covenant.org/translations
11
+ That's it. No enforcement ladder, no formal committee. If something feels off, reach out at swastik.thapaliya@gmail.com and we'll sort it out like adults.
data/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # Docsmith
2
2
 
3
+ [![Gem Version](https://badge.fury.io/rb/docsmith.svg)](https://rubygems.org/gems/docsmith)
4
+
3
5
  Docsmith adds snapshot-based versioning, format-aware diffs, and inline comments to any
4
6
  ActiveRecord model — with zero system dependencies.
5
7
 
@@ -35,10 +37,36 @@ article.save!
35
37
  article.save_version!(author: current_user, summary: "First draft")
36
38
 
37
39
  result = article.diff_from(1)
38
- result.additions # word-level count for markdown
40
+ result.stats # => {"insertions"=>1, "deletions"=>0, "replacements"=>1, "total"=>2}
41
+ result.changes # grouped edits with character offsets and line numbers
39
42
  result.to_html # <ins>/<del> markup
43
+ result.as_json # canonical JSON payload, identical when nested
44
+ ```
45
+
46
+ ## Example App
47
+
48
+ A self-contained Sinatra demo is in [`demo/`](demo/). It shows versioning, diffs, and comments working end-to-end — no Rails required.
49
+
50
+ ```bash
51
+ cd demo
52
+ bundle install
53
+ bundle exec rackup
54
+ # open http://localhost:9292
40
55
  ```
41
56
 
57
+ The demo loads the gem from the parent directory as a path dependency, so the
58
+ `bundle exec` prefix is required. `bundle exec ruby app.rb` also works and
59
+ serves on port 4567.
60
+
61
+ Two sections: **Articles** covers markdown versioning, diffs, tags, and comments.
62
+ **HTML & Sanitizer** uses `content_type :html` to show what `render(:html)` returns
63
+ under each `config.html_sanitizer` mode, side by side, against content containing a
64
+ `<script>` tag.
65
+
66
+ The demo installs `rails-html-sanitizer` to demonstrate the recommended sanitizer
67
+ setup, and `erubi` to turn on ERB escaping. Neither is a Docsmith dependency — the
68
+ gem itself still has none beyond ActiveRecord, ActiveSupport, and diff-lcs.
69
+
42
70
  ## Documentation
43
71
 
44
72
  See **[USAGE.md](USAGE.md)** for full documentation including:
@@ -61,6 +89,12 @@ bundle exec rspec # run tests
61
89
  bin/console # interactive console
62
90
  ```
63
91
 
92
+ ## How this was built
93
+
94
+ Honest note: this gem was built over two weekends using [Claude Code](https://claude.ai/code) with the superpowers plugin. Not vibe-coding — the planning, architecture decisions, and implementation were all deliberate. If you're curious how it came together, the planning docs and implementation notes are in [docs/superpowers](docs/superpowers).
95
+
96
+ Early designs got ambitious fast — branching, merging, conflict resolution. Turns out that's a lot of machinery for what is ultimately a document version manager. I stripped it back to what actually matters: snapshots, diffs, and comments. You can see all the planning in [docs/superpowers](docs/superpowers).
97
+
64
98
  ## License
65
99
 
66
100
  MIT — see [LICENSE.txt](LICENSE.txt).