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
|
@@ -6,56 +6,156 @@ require "cgi"
|
|
|
6
6
|
module Docsmith
|
|
7
7
|
module Diff
|
|
8
8
|
module Renderers
|
|
9
|
-
# Line-level diff renderer
|
|
10
|
-
#
|
|
11
|
-
#
|
|
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
|
-
#
|
|
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>]
|
|
57
|
+
# @return [Array<Hash>] edit hashes with :type, :old and :new
|
|
18
58
|
def compute(old_content, new_content)
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
77
|
+
edits
|
|
40
78
|
end
|
|
41
79
|
|
|
42
|
-
# Renders
|
|
80
|
+
# Renders grouped edits as an HTML diff representation.
|
|
43
81
|
#
|
|
44
|
-
# @param
|
|
82
|
+
# @param edits [Array<Hash>]
|
|
45
83
|
# @return [String] HTML string
|
|
46
|
-
def render_html(
|
|
47
|
-
lines =
|
|
48
|
-
case
|
|
49
|
-
when :
|
|
50
|
-
|
|
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
|
data/lib/docsmith/diff/result.rb
CHANGED
|
@@ -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
|
-
#
|
|
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
|
|
31
|
-
def
|
|
32
|
-
changes.count { |c| c[:type] == :
|
|
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
|
|
40
|
+
# @return [Integer] number of pure deletions
|
|
36
41
|
def deletions
|
|
37
|
-
changes.count { |c| c[:type] == :
|
|
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
|
-
#
|
|
46
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
}
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
data/lib/docsmith/errors.rb
CHANGED
|
@@ -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
|
|
8
|
-
#
|
|
9
|
-
#
|
|
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
|
|
13
|
-
# @return [String] JSON representation of the version
|
|
14
|
-
def render(version, **
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
data/lib/docsmith/version.rb
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Docsmith
|
|
4
|
-
VERSION = "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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
#
|
|
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.
|
|
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-
|
|
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/
|
|
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:
|