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.
@@ -6,56 +6,156 @@ require "cgi"
6
6
  module Docsmith
7
7
  module Diff
8
8
  module Renderers
9
- # Line-level diff renderer using diff-lcs.
10
- # Handles all content types (markdown, html, json) for Phase 2.
11
- # Register content-type-specific renderers via Renderers::Registry when needed.
9
+ # Line-level diff renderer, and the base for the format-aware parsers.
10
+ #
11
+ # Subclasses override #tokenize only. All grouping, offset arithmetic, and
12
+ # HTML rendering lives here, so every content type produces the same edit
13
+ # structure and only the token boundaries differ.
14
+ #
15
+ # An edit is a contiguous run of changes collapsed into one entry:
16
+ #
17
+ # { type: :replace,
18
+ # old: { start: 0, end: 11, line: 1, column: 1, text: "my document" },
19
+ # new: { start: 0, end: 30, line: 1, column: 1, text: "<h1>..." } }
20
+ #
21
+ # Both sides are always present. A pure insertion carries a zero-width old
22
+ # span at the insertion point; a pure deletion a zero-width new span. Every
23
+ # edit therefore has identical keys, and offsets on the two sides never
24
+ # share a coordinate space.
25
+ #
26
+ # `text` is sliced straight out of the source between the run's offsets, so
27
+ # whitespace the tokenizer discarded is preserved verbatim and both
28
+ # documents round-trip exactly.
12
29
  class Base
13
- # Computes line-level changes between two content strings.
30
+ # Actions that consume a token from the old side / the new side.
31
+ OLD_SIDE = %w[- !].freeze
32
+ NEW_SIDE = %w[+ !].freeze
33
+
34
+ # Splits content into [text, start_offset] pairs. Lines here, excluding
35
+ # the newline itself. Override in subclasses for other content types.
36
+ #
37
+ # The newline is deliberately not part of the token: including it made
38
+ # appending a line report the *previous* line as changed, because that
39
+ # line gained a trailing newline. Empty lines are preserved as empty
40
+ # tokens so that blank-line changes are still detected.
41
+ #
42
+ # @param content [String]
43
+ # @return [Array<Array(String, Integer)>]
44
+ def tokenize(content)
45
+ offset = 0
46
+ content.to_s.split("\n", -1).map do |line|
47
+ token = [line, offset]
48
+ offset += line.length + 1
49
+ token
50
+ end
51
+ end
52
+
53
+ # Computes grouped edits between two content strings.
14
54
  #
15
55
  # @param old_content [String]
16
56
  # @param new_content [String]
17
- # @return [Array<Hash>] change hashes with :type, :line, and content fields
57
+ # @return [Array<Hash>] edit hashes with :type, :old and :new
18
58
  def compute(old_content, new_content)
19
- old_lines = old_content.split("\n", -1)
20
- new_lines = new_content.split("\n", -1)
21
- changes = []
22
-
23
- ::Diff::LCS.sdiff(old_lines, new_lines).each do |hunk|
24
- case hunk.action
25
- when "+"
26
- changes << { type: :addition, line: hunk.new_position + 1, content: hunk.new_element.to_s }
27
- when "-"
28
- changes << { type: :deletion, line: hunk.old_position + 1, content: hunk.old_element.to_s }
29
- when "!"
30
- changes << {
31
- type: :modification,
32
- line: hunk.old_position + 1,
33
- old_content: hunk.old_element.to_s,
34
- new_content: hunk.new_element.to_s
35
- }
59
+ old_content = old_content.to_s
60
+ new_content = new_content.to_s
61
+ old_tokens = tokenize(old_content)
62
+ new_tokens = tokenize(new_content)
63
+
64
+ edits = []
65
+ run = []
66
+
67
+ ::Diff::LCS.sdiff(old_tokens.map(&:first), new_tokens.map(&:first)).each do |hunk|
68
+ if hunk.action == "="
69
+ edits << build_edit(run, old_tokens, new_tokens, old_content, new_content) unless run.empty?
70
+ run = []
71
+ else
72
+ run << hunk
36
73
  end
37
74
  end
75
+ edits << build_edit(run, old_tokens, new_tokens, old_content, new_content) unless run.empty?
38
76
 
39
- changes
77
+ edits
40
78
  end
41
79
 
42
- # Renders a change array as an HTML diff representation.
80
+ # Renders grouped edits as an HTML diff representation.
43
81
  #
44
- # @param changes [Array<Hash>]
82
+ # @param edits [Array<Hash>]
45
83
  # @return [String] HTML string
46
- def render_html(changes)
47
- lines = changes.map do |change|
48
- case change[:type]
49
- when :addition
50
- %(<ins class="docsmith-addition">#{CGI.escapeHTML(change[:content])}</ins>)
51
- when :deletion
52
- %(<del class="docsmith-deletion">#{CGI.escapeHTML(change[:content])}</del>)
53
- when :modification
54
- %(<del class="docsmith-deletion">#{CGI.escapeHTML(change[:old_content])}</del><ins class="docsmith-addition">#{CGI.escapeHTML(change[:new_content])}</ins>)
84
+ def render_html(edits)
85
+ lines = edits.map do |edit|
86
+ case edit[:type]
87
+ when :insert then insertion(edit[:new][:text])
88
+ when :delete then deletion(edit[:old][:text])
89
+ when :replace then deletion(edit[:old][:text]) + insertion(edit[:new][:text])
55
90
  end
56
91
  end
57
92
  %(<div class="docsmith-diff">#{lines.join("\n")}</div>)
58
93
  end
94
+
95
+ private
96
+
97
+ def insertion(text)
98
+ %(<ins class="docsmith-addition">#{CGI.escapeHTML(text.to_s)}</ins>)
99
+ end
100
+
101
+ def deletion(text)
102
+ %(<del class="docsmith-deletion">#{CGI.escapeHTML(text.to_s)}</del>)
103
+ end
104
+
105
+ # Collects every match with its start offset. scan sets Regexp.last_match
106
+ # per iteration when a block is given.
107
+ def scan_with_offsets(content, pattern)
108
+ matches = []
109
+ content.to_s.scan(pattern) { matches << [::Regexp.last_match(0), ::Regexp.last_match.begin(0)] }
110
+ matches
111
+ end
112
+
113
+ def build_edit(run, old_tokens, new_tokens, old_content, new_content)
114
+ old_indexes = run.select { |h| OLD_SIDE.include?(h.action) }.map(&:old_position)
115
+ new_indexes = run.select { |h| NEW_SIDE.include?(h.action) }.map(&:new_position)
116
+
117
+ {
118
+ type: edit_type(old_indexes, new_indexes),
119
+ old: span(old_content, old_tokens, old_indexes, run.first.old_position),
120
+ new: span(new_content, new_tokens, new_indexes, run.first.new_position)
121
+ }
122
+ end
123
+
124
+ def edit_type(old_indexes, new_indexes)
125
+ return :insert if old_indexes.empty?
126
+ return :delete if new_indexes.empty?
127
+
128
+ :replace
129
+ end
130
+
131
+ # @param indexes [Array<Integer>] token indexes this run touches on this side
132
+ # @param fallback_index [Integer] token index to anchor a zero-width span at
133
+ def span(content, tokens, indexes, fallback_index)
134
+ if indexes.empty?
135
+ start = token_start(tokens, fallback_index, content)
136
+ finish = start
137
+ else
138
+ last_text, last_start = tokens[indexes.last]
139
+ start = tokens[indexes.first][1]
140
+ finish = last_start + last_text.length
141
+ end
142
+
143
+ line, column = line_and_column(content, start)
144
+ { start: start, end: finish, line: line, column: column, text: content[start...finish].to_s }
145
+ end
146
+
147
+ # Character offset a token begins at; end of content when past the last token.
148
+ def token_start(tokens, index, content)
149
+ index < tokens.length ? tokens[index][1] : content.length
150
+ end
151
+
152
+ # 1-indexed line and column for a character offset.
153
+ def line_and_column(content, offset)
154
+ prefix = content[0, offset].to_s
155
+ last_newline = prefix.rindex("\n")
156
+
157
+ [prefix.count("\n") + 1, last_newline.nil? ? offset + 1 : offset - last_newline]
158
+ end
59
159
  end
60
160
  end
61
161
  end
@@ -13,7 +13,12 @@ module Docsmith
13
13
  attr_reader :from_version
14
14
  # @return [Integer] version_number of the to (newer) version
15
15
  attr_reader :to_version
16
- # @return [Array<Hash>] change hashes produced by Renderers::Base#compute
16
+ # Grouped edits produced by Renderers::Base#compute. Symbol-keyed mirror of
17
+ # the serialized form: each entry is
18
+ # { type: :insert|:delete|:replace, old: {...span}, new: {...span} }
19
+ # where a span is { start:, end:, line:, column:, text: }.
20
+ #
21
+ # @return [Array<Hash>]
17
22
  attr_reader :changes
18
23
 
19
24
  # @param content_type [String]
@@ -27,14 +32,34 @@ module Docsmith
27
32
  @changes = changes
28
33
  end
29
34
 
30
- # @return [Integer] number of added lines
31
- def additions
32
- changes.count { |c| c[:type] == :addition }
35
+ # @return [Integer] number of pure insertions
36
+ def insertions
37
+ changes.count { |c| c[:type] == :insert }
33
38
  end
34
39
 
35
- # @return [Integer] number of deleted lines
40
+ # @return [Integer] number of pure deletions
36
41
  def deletions
37
- changes.count { |c| c[:type] == :deletion }
42
+ changes.count { |c| c[:type] == :delete }
43
+ end
44
+
45
+ # @return [Integer] number of replacements (an old span became a new one)
46
+ def replacements
47
+ changes.count { |c| c[:type] == :replace }
48
+ end
49
+
50
+ # Edit counts by kind. insertions and deletions are pure; a replacement is
51
+ # counted once rather than as one of each, so a rewritten document never
52
+ # reports 0/0 alongside a non-empty changes array. For git-style totals, add
53
+ # replacements to either side.
54
+ #
55
+ # @return [Hash] string-keyed counts
56
+ def stats
57
+ {
58
+ "insertions" => insertions,
59
+ "deletions" => deletions,
60
+ "replacements" => replacements,
61
+ "total" => changes.length
62
+ }
38
63
  end
39
64
 
40
65
  # @return [String] HTML diff representation
@@ -42,35 +67,55 @@ module Docsmith
42
67
  Renderers::Registry.for(content_type).new.render_html(changes)
43
68
  end
44
69
 
45
- # @return [String] JSON diff representation matching the documented schema
46
- def to_json(*)
70
+ # The canonical JSON representation, and the single source of truth for it.
71
+ #
72
+ # Defining as_json is what makes nesting work: ActiveSupport calls it for
73
+ # `render json: { diff: result }`, `[result].to_json`, and JSON.generate.
74
+ # Without it those fell through to Object#as_json (instance_values), which
75
+ # silently dropped "stats" and emitted "line" instead of "position".
76
+ #
77
+ # @param options [Hash, nil] accepted for API compatibility; unused
78
+ # @return [Hash] string-keyed, JSON-ready
79
+ def as_json(options = nil) # rubocop:disable Lint/UnusedMethodArgument
47
80
  {
48
- content_type: content_type,
49
- from_version: from_version,
50
- to_version: to_version,
51
- stats: { additions: additions, deletions: deletions },
52
- changes: changes.map { |c| serialize_change(c) }
53
- }.to_json
81
+ "schema_version" => Docsmith::JSON_SCHEMA_VERSION,
82
+ "content_type" => content_type,
83
+ "from_version" => from_version,
84
+ "to_version" => to_version,
85
+ "stats" => stats,
86
+ "changes" => changes.map { |c| serialize_change(c) }
87
+ }
88
+ end
89
+
90
+ # Args are forwarded so JSON.pretty_generate indents a nested Result
91
+ # instead of splicing it in as one compact line.
92
+ #
93
+ # @return [String] JSON diff representation matching the documented schema
94
+ def to_json(*args)
95
+ as_json.to_json(*args)
54
96
  end
55
97
 
56
98
  private
57
99
 
100
+ # Every edit serializes to the same keys, and the two sides never share a
101
+ # coordinate space. An absent side is a zero-width span marking the point
102
+ # where text was inserted into, or removed from, that document.
58
103
  def serialize_change(change)
59
- case change[:type]
60
- when :addition
61
- { type: "addition", position: { line: change[:line] }, content: change[:content] }
62
- when :deletion
63
- { type: "deletion", position: { line: change[:line] }, content: change[:content] }
64
- when :modification
65
- {
66
- type: "modification",
67
- position: { line: change[:line] },
68
- old_content: change[:old_content],
69
- new_content: change[:new_content]
70
- }
71
- else
72
- change.transform_keys(&:to_s)
73
- end
104
+ {
105
+ "type" => change[:type].to_s,
106
+ "old" => serialize_span(change[:old]),
107
+ "new" => serialize_span(change[:new])
108
+ }
109
+ end
110
+
111
+ def serialize_span(span)
112
+ {
113
+ "start" => span[:start],
114
+ "end" => span[:end],
115
+ "line" => span[:line],
116
+ "column" => span[:column],
117
+ "text" => span[:text]
118
+ }
74
119
  end
75
120
  end
76
121
  end
@@ -46,5 +46,20 @@ module Docsmith
46
46
  else raise ArgumentError, "Unknown render format: #{format}. Supported: :html, :json"
47
47
  end
48
48
  end
49
+
50
+ # The JSON export envelope as a Hash, for embedding in a larger API response
51
+ # without a JSON round-trip. Same shape as render(:json).
52
+ #
53
+ # Note this is deliberately NOT `as_json`. DocumentVersion is an
54
+ # ActiveRecord::Base, and overriding as_json would silently change what
55
+ # `render json: @version` returns for every app already relying on standard
56
+ # attribute serialization.
57
+ #
58
+ # @param options [Hash] :include_parsed adds "data" with the parsed document
59
+ # @return [Hash] string-keyed, JSON-ready
60
+ # @raise [Docsmith::InvalidJsonContent] if :include_parsed is set and content does not parse
61
+ def export(**options)
62
+ Rendering::JsonRenderer.new.to_h(self, **options)
63
+ end
49
64
  end
50
65
  end
@@ -15,4 +15,14 @@ module Docsmith
15
15
 
16
16
  # Raised when tag_version! is called with a name already used on this document.
17
17
  class TagAlreadyExists < Error; end
18
+
19
+ # Raised when a JSON export is asked to parse content (include_parsed: true)
20
+ # on a version whose content_type is "json" but whose content is not valid JSON.
21
+ # The default export path never parses, so it never raises this.
22
+ class InvalidJsonContent < Error; end
23
+
24
+ # Raised when configuration.html_sanitizer is neither nil, :unsafe_raw, nor callable.
25
+ # Fails loudly rather than falling back, so a misconfigured sanitizer can never
26
+ # silently degrade into rendering untrusted HTML verbatim.
27
+ class InvalidHtmlSanitizer < Error; end
18
28
  end
@@ -8,6 +8,7 @@ module Docsmith
8
8
  # Renders a DocumentVersion's content as an HTML string.
9
9
  # Markdown is shown pre-formatted (no external gem dependency).
10
10
  # JSON is pretty-printed inside a pre block.
11
+ # html content passes through Docsmith.configuration.html_sanitizer.
11
12
  # Subclass and override #render to plug in a markdown gem (e.g. redcarpet).
12
13
  class HtmlRenderer
13
14
  # @param version [Docsmith::DocumentVersion]
@@ -19,7 +20,7 @@ module Docsmith
19
20
 
20
21
  case content_type
21
22
  when "html"
22
- content
23
+ sanitize_html(content)
23
24
  when "markdown"
24
25
  "<pre class=\"docsmith-markdown\">#{CGI.escapeHTML(content)}</pre>"
25
26
  when "json"
@@ -31,6 +32,30 @@ module Docsmith
31
32
  rescue JSON::ParserError
32
33
  "<pre>#{CGI.escapeHTML(content)}</pre>"
33
34
  end
35
+
36
+ private
37
+
38
+ # Stored HTML is untrusted input. Returning it verbatim is stored XSS, so
39
+ # that only happens when the host app explicitly asks for it.
40
+ # See Docsmith::Configuration#html_sanitizer for the three modes.
41
+ #
42
+ # @param content [String] raw stored HTML
43
+ # @return [String]
44
+ def sanitize_html(content)
45
+ sanitizer = Docsmith.configuration.html_sanitizer
46
+
47
+ case sanitizer
48
+ when :unsafe_raw then content
49
+ when nil then "<pre class=\"docsmith-html\">#{CGI.escapeHTML(content)}</pre>"
50
+ else
51
+ raise Docsmith::InvalidHtmlSanitizer, <<~MSG unless sanitizer.respond_to?(:call)
52
+ html_sanitizer must respond to #call, or be :unsafe_raw or nil.
53
+ Got: #{sanitizer.inspect}
54
+ MSG
55
+
56
+ sanitizer.call(content).to_s
57
+ end
58
+ end
34
59
  end
35
60
  end
36
61
  end
@@ -4,25 +4,72 @@ require "json"
4
4
 
5
5
  module Docsmith
6
6
  module Rendering
7
- # Renders a DocumentVersion's content as a JSON string.
8
- # For json content_type: re-parses and pretty-prints.
9
- # For other types: wraps content in a JSON envelope.
7
+ # Renders a DocumentVersion as a JSON export envelope.
8
+ #
9
+ # One shape for every content_type. Before 0.2.0 this returned two
10
+ # incompatible schemas — a bare pretty-printed document for content_type
11
+ # "json", an envelope for everything else — so no client could parse it
12
+ # generically.
13
+ #
14
+ # "content" is always the exact stored string, byte for byte. This is a
15
+ # versioning gem: an export that re-formatted the snapshot would not match
16
+ # what was versioned, and a client diffing two exports locally would get
17
+ # different results than the gem's own diff.
18
+ #
19
+ # Pass include_parsed: true to additionally receive the parsed document under
20
+ # "data". That is the only code path that parses, so the default export
21
+ # cannot fail on malformed content.
10
22
  class JsonRenderer
11
23
  # @param version [Docsmith::DocumentVersion]
12
- # @param options [Hash] unused in Phase 2
13
- # @return [String] JSON representation of the version content
14
- def render(version, **options)
15
- content = version.content.to_s
16
- content_type = version.content_type.to_s
17
-
18
- case content_type
19
- when "json"
20
- JSON.pretty_generate(JSON.parse(content))
21
- else
22
- { content_type: content_type, content: content }.to_json
23
- end
24
- rescue JSON::ParserError
25
- { content_type: content_type, content: content, error: "invalid_json" }.to_json
24
+ # @param include_parsed [Boolean] add "data" with the parsed document (json only)
25
+ # @return [String] JSON representation of the version
26
+ def render(version, include_parsed: false, **_options)
27
+ to_h(version, include_parsed: include_parsed).to_json
28
+ end
29
+
30
+ # The canonical envelope, and the single source of truth for its shape.
31
+ #
32
+ # author is emitted as type/id only. The gem cannot know the host app's
33
+ # author class, which fields it exposes, or which of them are personal
34
+ # data, so it never serializes the record itself.
35
+ #
36
+ # @param version [Docsmith::DocumentVersion]
37
+ # @param include_parsed [Boolean]
38
+ # @return [Hash] string-keyed, JSON-ready
39
+ # @raise [Docsmith::InvalidJsonContent] if include_parsed is set and content does not parse
40
+ def to_h(version, include_parsed: false, **_options)
41
+ envelope = {
42
+ "schema_version" => Docsmith::JSON_SCHEMA_VERSION,
43
+ "document_id" => version.document_id,
44
+ "version_number" => version.version_number,
45
+ "content_type" => version.content_type.to_s,
46
+ "content" => version.content.to_s,
47
+ "change_summary" => version.change_summary,
48
+ "author" => author_for(version),
49
+ "metadata" => version.metadata,
50
+ "created_at" => version.created_at&.utc&.iso8601(3)
51
+ }
52
+
53
+ return envelope unless include_parsed && envelope["content_type"] == "json"
54
+
55
+ envelope.merge("data" => parse(envelope["content"], version))
56
+ end
57
+
58
+ private
59
+
60
+ # @return [Hash, nil]
61
+ def author_for(version)
62
+ return nil if version.author_type.nil? && version.author_id.nil?
63
+
64
+ { "type" => version.author_type, "id" => version.author_id }
65
+ end
66
+
67
+ def parse(content, version)
68
+ JSON.parse(content)
69
+ rescue JSON::ParserError => e
70
+ raise Docsmith::InvalidJsonContent,
71
+ "version #{version.version_number} of document #{version.document_id} " \
72
+ "has content_type \"json\" but its content does not parse: #{e.message}"
26
73
  end
27
74
  end
28
75
  end
@@ -1,5 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Docsmith
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
+
6
+ # Wire-format version for every JSON export the gem produces, emitted as the
7
+ # "schema_version" key. Independent of VERSION: it changes only when the shape
8
+ # of an export changes, so third-party clients can branch on it instead of
9
+ # sniffing for keys or pinning a gem version.
10
+ JSON_SCHEMA_VERSION = 1
5
11
  end
@@ -12,7 +12,12 @@ module Docsmith
12
12
 
13
13
  desc "Creates the Docsmith migration and initializer."
14
14
 
15
- def create_migration
15
+ # NOTE: do not name this `create_migration`. Rails::Generators::Migration
16
+ # defines create_migration(destination, data, config) and migration_template
17
+ # calls it internally, so a same-named zero-arg action method shadows it and
18
+ # the generator dies with "wrong number of arguments (given 3, expected 0)".
19
+ # That is what happened in 0.1.0.
20
+ def create_migration_file
16
21
  migration_template(
17
22
  "create_docsmith_tables.rb.erb",
18
23
  "db/migrate/create_docsmith_tables.rb"
@@ -22,6 +27,23 @@ module Docsmith
22
27
  def create_initializer
23
28
  template "docsmith_initializer.rb.erb", "config/initializers/docsmith.rb"
24
29
  end
30
+
31
+ private
32
+
33
+ # Column type for JSON payload columns, chosen per adapter.
34
+ # PostgreSQL gets :jsonb (indexable); every other adapter gets :json,
35
+ # which MySQL and SQLite both support and which casts to Hash the same way.
36
+ # Reads the db config rather than the connection so generation works offline.
37
+ #
38
+ # Matches /postg/ rather than a "postgresql" prefix so PostgreSQL-derived
39
+ # adapters (postgis, postgresql_makara) still get jsonb.
40
+ #
41
+ # @return [String]
42
+ def json_column_type
43
+ ActiveRecord::Base.connection_db_config.adapter.to_s.match?(/postg/i) ? "jsonb" : "json"
44
+ rescue StandardError
45
+ "json"
46
+ end
25
47
  end
26
48
  end
27
49
  end
@@ -10,7 +10,7 @@ class CreateDocsmithTables < ActiveRecord::Migration[<%= ActiveRecord::Migration
10
10
  t.datetime :last_versioned_at
11
11
  t.string :subject_type
12
12
  t.bigint :subject_id
13
- t.jsonb :metadata, null: false, default: {}
13
+ t.<%= json_column_type %> :metadata, null: false, default: {}
14
14
  t.timestamps
15
15
  end
16
16
  add_index :docsmith_documents, %i[subject_type subject_id]
@@ -23,7 +23,7 @@ class CreateDocsmithTables < ActiveRecord::Migration[<%= ActiveRecord::Migration
23
23
  t.string :author_type
24
24
  t.bigint :author_id
25
25
  t.string :change_summary
26
- t.jsonb :metadata, null: false, default: {}
26
+ t.<%= json_column_type %> :metadata, null: false, default: {}
27
27
  t.datetime :created_at, null: false
28
28
  end
29
29
  add_index :docsmith_versions, %i[document_id version_number], unique: true
@@ -48,7 +48,7 @@ class CreateDocsmithTables < ActiveRecord::Migration[<%= ActiveRecord::Migration
48
48
  t.bigint :author_id
49
49
  t.text :body, null: false
50
50
  t.string :anchor_type, null: false, default: "document"
51
- t.jsonb :anchor_data, null: false, default: {}
51
+ t.<%= json_column_type %> :anchor_data, null: false, default: {}
52
52
  t.boolean :resolved, null: false, default: false
53
53
  t.string :resolved_by_type
54
54
  t.bigint :resolved_by_id
@@ -10,7 +10,19 @@ Docsmith.configure do |config|
10
10
  # config.max_versions = nil # gem default: nil (unlimited)
11
11
  # config.content_extractor = nil # example: ->(record) { record.body.to_html }
12
12
  # config.table_prefix = "docsmith" # gem default: "docsmith"
13
- # config.diff_context_lines = 3
13
+ #
14
+ # How version.render(:html) treats stored HTML content.
15
+ #
16
+ # Stored HTML is untrusted input — rendering it verbatim is stored XSS if any
17
+ # of it came from a user. Docsmith ships no sanitizer, because a safe one needs
18
+ # a real HTML parser and that would break the zero-system-dependency promise.
19
+ # Left unset, html content is ESCAPED: safe, and obvious enough that you notice.
20
+ #
21
+ # Rails already bundles a sanitizer via ActionView, so opting in is one line:
22
+ # config.html_sanitizer = ->(html) { Rails::HTML5::SafeListSanitizer.new.sanitize(html) }
23
+ #
24
+ # Only for HTML you generate yourself and fully trust:
25
+ # config.html_sanitizer = :unsafe_raw
14
26
  #
15
27
  # Event hooks (fires synchronously before AS::Notifications):
16
28
  # config.on(:version_created) { |event| Rails.logger.info "v#{event.version.version_number} saved" }
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: docsmith
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - swastik009
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-04-12 00:00:00.000000000 Z
11
+ date: 2026-07-26 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -52,6 +52,20 @@ dependencies:
52
52
  - - "~>"
53
53
  - !ruby/object:Gem::Version
54
54
  version: '1.5'
55
+ - !ruby/object:Gem::Dependency
56
+ name: railties
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '7.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '7.0'
55
69
  - !ruby/object:Gem::Dependency
56
70
  name: rspec
57
71
  requirement: !ruby/object:Gem::Requirement
@@ -124,18 +138,12 @@ executables: []
124
138
  extensions: []
125
139
  extra_rdoc_files: []
126
140
  files:
127
- - ".rspec"
128
- - ".rspec_status"
129
141
  - CHANGELOG.md
130
142
  - CODE_OF_CONDUCT.md
131
143
  - LICENSE.txt
132
144
  - README.md
133
145
  - Rakefile
134
146
  - USAGE.md
135
- - docs/superpowers/plans/2026-04-01-docsmith-full-plan.md
136
- - docs/superpowers/plans/2026-04-08-parsers-remove-branches-docs.md
137
- - docs/superpowers/specs/2026-04-01-docsmith-phase1-design.md
138
- - docsmith_spec.md
139
147
  - lib/docsmith.rb
140
148
  - lib/docsmith/auto_save.rb
141
149
  - lib/docsmith/comments/anchor.rb
@@ -173,7 +181,7 @@ licenses:
173
181
  metadata:
174
182
  homepage_uri: https://www.altcipher.com
175
183
  source_code_uri: https://github.com/swastik009/docsmith
176
- changelog_uri: https://github.com/swastik009/docsmith/blob/main/CHANGELOG.md
184
+ changelog_uri: https://github.com/swastik009/docsmith/blob/master/CHANGELOG.md
177
185
  post_install_message:
178
186
  rdoc_options: []
179
187
  require_paths: