docsmith 0.1.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.
Files changed (45) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rspec_status +212 -0
  4. data/CHANGELOG.md +5 -0
  5. data/CODE_OF_CONDUCT.md +132 -0
  6. data/LICENSE.txt +21 -0
  7. data/README.md +66 -0
  8. data/Rakefile +8 -0
  9. data/USAGE.md +510 -0
  10. data/docs/superpowers/plans/2026-04-01-docsmith-full-plan.md +6459 -0
  11. data/docs/superpowers/plans/2026-04-08-parsers-remove-branches-docs.md +2112 -0
  12. data/docs/superpowers/specs/2026-04-01-docsmith-phase1-design.md +340 -0
  13. data/docsmith_spec.md +630 -0
  14. data/lib/docsmith/auto_save.rb +29 -0
  15. data/lib/docsmith/comments/anchor.rb +68 -0
  16. data/lib/docsmith/comments/comment.rb +44 -0
  17. data/lib/docsmith/comments/manager.rb +73 -0
  18. data/lib/docsmith/comments/migrator.rb +64 -0
  19. data/lib/docsmith/configuration.rb +95 -0
  20. data/lib/docsmith/diff/engine.rb +39 -0
  21. data/lib/docsmith/diff/parsers/html.rb +64 -0
  22. data/lib/docsmith/diff/parsers/markdown.rb +60 -0
  23. data/lib/docsmith/diff/renderers/base.rb +62 -0
  24. data/lib/docsmith/diff/renderers/registry.rb +41 -0
  25. data/lib/docsmith/diff/renderers.rb +10 -0
  26. data/lib/docsmith/diff/result.rb +77 -0
  27. data/lib/docsmith/diff.rb +6 -0
  28. data/lib/docsmith/document.rb +44 -0
  29. data/lib/docsmith/document_version.rb +50 -0
  30. data/lib/docsmith/errors.rb +18 -0
  31. data/lib/docsmith/events/event.rb +19 -0
  32. data/lib/docsmith/events/hook_registry.rb +14 -0
  33. data/lib/docsmith/events/notifier.rb +22 -0
  34. data/lib/docsmith/rendering/html_renderer.rb +36 -0
  35. data/lib/docsmith/rendering/json_renderer.rb +29 -0
  36. data/lib/docsmith/version.rb +5 -0
  37. data/lib/docsmith/version_manager.rb +143 -0
  38. data/lib/docsmith/version_tag.rb +25 -0
  39. data/lib/docsmith/versionable.rb +252 -0
  40. data/lib/docsmith.rb +52 -0
  41. data/lib/generators/docsmith/install/install_generator.rb +27 -0
  42. data/lib/generators/docsmith/install/templates/create_docsmith_tables.rb.erb +64 -0
  43. data/lib/generators/docsmith/install/templates/docsmith_initializer.rb.erb +19 -0
  44. data/sig/docsmith.rbs +4 -0
  45. metadata +196 -0
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Docsmith
6
+ module Diff
7
+ # Holds the computed diff between two DocumentVersion records.
8
+ # Produced by Diff::Engine.between; consumed by callers for stats and rendering.
9
+ class Result
10
+ # @return [String] content type of the diffed document ("markdown", "html", "json")
11
+ attr_reader :content_type
12
+ # @return [Integer] version_number of the from (older) version
13
+ attr_reader :from_version
14
+ # @return [Integer] version_number of the to (newer) version
15
+ attr_reader :to_version
16
+ # @return [Array<Hash>] change hashes produced by Renderers::Base#compute
17
+ attr_reader :changes
18
+
19
+ # @param content_type [String]
20
+ # @param from_version [Integer]
21
+ # @param to_version [Integer]
22
+ # @param changes [Array<Hash>]
23
+ def initialize(content_type:, from_version:, to_version:, changes:)
24
+ @content_type = content_type
25
+ @from_version = from_version
26
+ @to_version = to_version
27
+ @changes = changes
28
+ end
29
+
30
+ # @return [Integer] number of added lines
31
+ def additions
32
+ changes.count { |c| c[:type] == :addition }
33
+ end
34
+
35
+ # @return [Integer] number of deleted lines
36
+ def deletions
37
+ changes.count { |c| c[:type] == :deletion }
38
+ end
39
+
40
+ # @return [String] HTML diff representation
41
+ def to_html
42
+ Renderers::Registry.for(content_type).new.render_html(changes)
43
+ end
44
+
45
+ # @return [String] JSON diff representation matching the documented schema
46
+ def to_json(*)
47
+ {
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
54
+ end
55
+
56
+ private
57
+
58
+ 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
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docsmith
4
+ module Diff
5
+ end
6
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docsmith
4
+ # AR model backed by docsmith_documents.
5
+ # Serves as both a standalone versioned document and the shadow record
6
+ # auto-created when Docsmith::Versionable is included on any AR model.
7
+ #
8
+ # Shadow record lifecycle:
9
+ # include Docsmith::Versionable on Article → first save_version! call does:
10
+ # Docsmith::Document.find_or_create_by!(subject: article_instance)
11
+ # subject_type / subject_id link back to the originating record.
12
+ class Document < ActiveRecord::Base
13
+ self.table_name = "docsmith_documents"
14
+
15
+ belongs_to :subject, polymorphic: true, optional: true
16
+ has_many :document_versions,
17
+ -> { order(:version_number) },
18
+ foreign_key: :document_id,
19
+ class_name: "Docsmith::DocumentVersion",
20
+ dependent: :destroy
21
+ has_many :version_tags,
22
+ through: :document_versions,
23
+ class_name: "Docsmith::VersionTag"
24
+
25
+ validates :content_type, presence: true,
26
+ inclusion: { in: %w[html markdown json] }
27
+
28
+ # @return [Docsmith::DocumentVersion, nil] latest version by version_number
29
+ def current_version
30
+ document_versions.last
31
+ end
32
+
33
+ # Find or create the shadow Document for an existing AR record.
34
+ # @param record [ActiveRecord::Base]
35
+ # @param field [Symbol, nil] ignored — content_field comes from class config
36
+ # @return [Docsmith::Document]
37
+ def self.from_record(record, field: nil)
38
+ find_or_create_by!(subject: record) do |doc|
39
+ doc.content_type = "markdown"
40
+ doc.title = record.respond_to?(:title) ? record.title.to_s : record.class.name
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docsmith
4
+ # Immutable content snapshot. Table is docsmith_versions.
5
+ # Class name is DocumentVersion (not Version) to avoid colliding with
6
+ # lib/docsmith/version.rb which holds the Docsmith::VERSION constant.
7
+ class DocumentVersion < ActiveRecord::Base
8
+ self.table_name = "docsmith_versions"
9
+
10
+ belongs_to :document,
11
+ class_name: "Docsmith::Document",
12
+ foreign_key: :document_id
13
+ belongs_to :author, polymorphic: true, optional: true
14
+ has_many :version_tags,
15
+ class_name: "Docsmith::VersionTag",
16
+ foreign_key: :version_id,
17
+ dependent: :destroy
18
+ has_many :comments,
19
+ class_name: "Docsmith::Comments::Comment",
20
+ foreign_key: :version_id,
21
+ dependent: :destroy
22
+
23
+ validates :version_number, presence: true,
24
+ uniqueness: { scope: :document_id }
25
+ validates :content, presence: true
26
+ validates :content_type, presence: true,
27
+ inclusion: { in: %w[html markdown json] }
28
+
29
+ # @return [Docsmith::DocumentVersion, nil]
30
+ def previous_version
31
+ document.document_versions
32
+ .where("version_number < ?", version_number)
33
+ .last
34
+ end
35
+
36
+ # Renders this version's content in the given output format.
37
+ #
38
+ # @param format [Symbol] :html or :json
39
+ # @param options [Hash] passed through to the renderer
40
+ # @return [String]
41
+ # @raise [ArgumentError] for unknown formats
42
+ def render(format, **options)
43
+ case format.to_sym
44
+ when :html then Rendering::HtmlRenderer.new.render(self, **options)
45
+ when :json then Rendering::JsonRenderer.new.render(self, **options)
46
+ else raise ArgumentError, "Unknown render format: #{format}. Supported: :html, :json"
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docsmith
4
+ # Base class for all Docsmith errors.
5
+ class Error < StandardError; end
6
+
7
+ # Raised when content_field returns a non-String and no content_extractor is configured.
8
+ class InvalidContentField < Error; end
9
+
10
+ # Raised when max_versions is set, all versions are tagged, and a new version would exceed the limit.
11
+ class MaxVersionsExceeded < Error; end
12
+
13
+ # Raised when a requested version_number does not exist on the document.
14
+ class VersionNotFound < Error; end
15
+
16
+ # Raised when tag_version! is called with a name already used on this document.
17
+ class TagAlreadyExists < Error; end
18
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docsmith
4
+ module Events
5
+ # Immutable payload for all Docsmith events.
6
+ # Fields on every event: record, document, version, author.
7
+ # Extra fields by event:
8
+ # version_restored → from_version
9
+ # version_tagged → tag_name
10
+ # comment_added/orphaned → comment (Phase 3)
11
+ # branch_created/merged → branch (Phase 4)
12
+ Event = Struct.new(
13
+ :record, :document, :version, :author,
14
+ :from_version, :tag_name,
15
+ :comment, :branch, :conflicts, :merge_result,
16
+ keyword_init: true
17
+ )
18
+ end
19
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docsmith
4
+ module Events
5
+ # Calls synchronous hooks registered via Docsmith.configure { |c| c.on(:event) { } }.
6
+ module HookRegistry
7
+ # @param event_name [Symbol]
8
+ # @param event [Docsmith::Events::Event]
9
+ def self.call(event_name, event)
10
+ Docsmith.configuration.hooks_for(event_name).each { |hook| hook.call(event) }
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/notifications"
4
+
5
+ module Docsmith
6
+ module Events
7
+ # Fires both AS::Notifications and callback hooks for every action.
8
+ # Instrument name format: "#{event_name}.docsmith" (e.g. "version_created.docsmith").
9
+ module Notifier
10
+ # @param event_name [Symbol]
11
+ # @param payload [Hash] keyword args forwarded to Event.new
12
+ # @return [Docsmith::Events::Event]
13
+ def self.instrument(event_name, **payload)
14
+ event = Event.new(**payload)
15
+ ActiveSupport::Notifications.instrument("#{event_name}.docsmith", payload) do
16
+ HookRegistry.call(event_name, event)
17
+ end
18
+ event
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+ require "json"
5
+
6
+ module Docsmith
7
+ module Rendering
8
+ # Renders a DocumentVersion's content as an HTML string.
9
+ # Markdown is shown pre-formatted (no external gem dependency).
10
+ # JSON is pretty-printed inside a pre block.
11
+ # Subclass and override #render to plug in a markdown gem (e.g. redcarpet).
12
+ class HtmlRenderer
13
+ # @param version [Docsmith::DocumentVersion]
14
+ # @param options [Hash] unused in Phase 2; available for subclasses
15
+ # @return [String] HTML representation of the version content
16
+ def render(version, **options)
17
+ content = version.content.to_s
18
+ content_type = version.content_type.to_s
19
+
20
+ case content_type
21
+ when "html"
22
+ content
23
+ when "markdown"
24
+ "<pre class=\"docsmith-markdown\">#{CGI.escapeHTML(content)}</pre>"
25
+ when "json"
26
+ pretty = JSON.pretty_generate(JSON.parse(content))
27
+ "<pre class=\"docsmith-json\">#{CGI.escapeHTML(pretty)}</pre>"
28
+ else
29
+ "<pre>#{CGI.escapeHTML(content)}</pre>"
30
+ end
31
+ rescue JSON::ParserError
32
+ "<pre>#{CGI.escapeHTML(content)}</pre>"
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Docsmith
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.
10
+ class JsonRenderer
11
+ # @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
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docsmith
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docsmith
4
+ # Service object for all version lifecycle operations.
5
+ # The Versionable mixin delegates here after resolving the shadow document.
6
+ # Always receives a Docsmith::Document instance.
7
+ module VersionManager
8
+ # Create a new DocumentVersion snapshot.
9
+ # Returns nil if content is identical to the latest version (string == check).
10
+ #
11
+ # @param document [Docsmith::Document]
12
+ # @param author [Object, nil]
13
+ # @param summary [String, nil]
14
+ # @param config [Hash] resolved config
15
+ # @return [Docsmith::DocumentVersion, nil]
16
+ def self.save!(document, author:, summary: nil, config: nil)
17
+ config ||= Configuration.resolve({}, Docsmith.configuration)
18
+ current = document.content.to_s
19
+ latest = document.document_versions.last
20
+
21
+ return nil if latest && latest.content == current
22
+
23
+ next_num = document.versions_count + 1
24
+
25
+ version = DocumentVersion.create!(
26
+ document: document,
27
+ version_number: next_num,
28
+ content: current,
29
+ content_type: document.content_type,
30
+ author: author,
31
+ change_summary: summary,
32
+ metadata: {}
33
+ )
34
+
35
+ document.update_columns(
36
+ versions_count: next_num,
37
+ last_versioned_at: Time.current
38
+ )
39
+ document.versions_count = next_num
40
+
41
+ prune_if_needed!(document, version, config) if config[:max_versions]
42
+
43
+ record = document.subject || document
44
+ Events::Notifier.instrument(:version_created,
45
+ record: record, document: document, version: version, author: author)
46
+
47
+ version
48
+ end
49
+
50
+ # Restore a previous version by creating a new version with its content.
51
+ # Fires :version_restored (not :version_created). Never mutates existing versions.
52
+ #
53
+ # @param document [Docsmith::Document]
54
+ # @param version [Integer] version_number to restore from
55
+ # @param author [Object, nil]
56
+ # @param config [Hash] resolved config
57
+ # @return [Docsmith::DocumentVersion] the new version
58
+ # @raise [Docsmith::VersionNotFound]
59
+ def self.restore!(document, version:, author:, config: nil)
60
+ config ||= Configuration.resolve({}, Docsmith.configuration)
61
+ from_version = document.document_versions.find_by(version_number: version)
62
+ raise VersionNotFound, "Version #{version} not found on this document" unless from_version
63
+
64
+ next_num = document.versions_count + 1
65
+
66
+ new_version = DocumentVersion.create!(
67
+ document: document,
68
+ version_number: next_num,
69
+ content: from_version.content,
70
+ content_type: document.content_type,
71
+ author: author,
72
+ change_summary: "Restored from v#{version}",
73
+ metadata: {}
74
+ )
75
+
76
+ document.update_columns(
77
+ content: from_version.content,
78
+ versions_count: next_num,
79
+ last_versioned_at: Time.current
80
+ )
81
+
82
+ record = document.subject || document
83
+ Events::Notifier.instrument(:version_restored,
84
+ record: record, document: document, version: new_version,
85
+ author: author, from_version: from_version)
86
+
87
+ new_version
88
+ end
89
+
90
+ # Tag a specific version with a name unique to this document.
91
+ #
92
+ # @param document [Docsmith::Document]
93
+ # @param version [Integer] version_number to tag
94
+ # @param name [String] unique per document
95
+ # @param author [Object, nil]
96
+ # @return [Docsmith::VersionTag]
97
+ # @raise [Docsmith::VersionNotFound]
98
+ # @raise [Docsmith::TagAlreadyExists]
99
+ def self.tag!(document, version:, name:, author:)
100
+ version_record = document.document_versions.find_by(version_number: version)
101
+ raise VersionNotFound, "Version #{version} not found on this document" unless version_record
102
+
103
+ if VersionTag.exists?(document_id: document.id, name: name)
104
+ raise TagAlreadyExists, "Tag '#{name}' already exists on this document"
105
+ end
106
+
107
+ tag = VersionTag.create!(
108
+ document: document,
109
+ version: version_record,
110
+ name: name,
111
+ author: author
112
+ )
113
+
114
+ record = document.subject || document
115
+ Events::Notifier.instrument(:version_tagged,
116
+ record: record, document: document, version: version_record,
117
+ author: author, tag_name: name)
118
+
119
+ tag
120
+ end
121
+
122
+ def self.prune_if_needed!(document, new_version, config)
123
+ max = config[:max_versions]
124
+ return unless max && document.versions_count > max
125
+
126
+ tagged_ids = VersionTag.where(document_id: document.id).select(:version_id)
127
+ oldest_untagged = document.document_versions
128
+ .where.not(id: tagged_ids)
129
+ .where.not(id: new_version.id)
130
+ .first
131
+
132
+ unless oldest_untagged
133
+ raise MaxVersionsExceeded,
134
+ "All #{document.versions_count} versions are tagged. Cannot prune to stay within " \
135
+ "max_versions: #{max}. Remove a tag or increase max_versions."
136
+ end
137
+
138
+ oldest_untagged.destroy!
139
+ document.update_column(:versions_count, document.versions_count - 1)
140
+ end
141
+ private_class_method :prune_if_needed!
142
+ end
143
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docsmith
4
+ # Named label on a DocumentVersion.
5
+ # Tag names are unique per document (not per version) — enforced at DB level
6
+ # via the unique index on [document_id, name] in docsmith_version_tags.
7
+ # document_id is denormalized on this table to enable that DB-level constraint.
8
+ class VersionTag < ActiveRecord::Base
9
+ self.table_name = "docsmith_version_tags"
10
+
11
+ belongs_to :document,
12
+ class_name: "Docsmith::Document",
13
+ foreign_key: :document_id
14
+ belongs_to :version,
15
+ class_name: "Docsmith::DocumentVersion",
16
+ foreign_key: :version_id
17
+ belongs_to :author, polymorphic: true, optional: true
18
+
19
+ validates :name, presence: true
20
+ validates :document_id, presence: true
21
+ validates :version_id, presence: true
22
+ validates :name, uniqueness: { scope: :document_id,
23
+ message: "already exists on this document" }
24
+ end
25
+ end