uniword 1.5.1 → 1.5.3

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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -0
  3. data/lib/uniword/batch/operation/file_result.rb +39 -0
  4. data/lib/uniword/batch/operation/repair_task.rb +34 -0
  5. data/lib/uniword/batch/operation/report.rb +66 -0
  6. data/lib/uniword/batch/operation/runner.rb +30 -0
  7. data/lib/uniword/batch/operation/task.rb +40 -0
  8. data/lib/uniword/batch/operation/verify_task.rb +27 -0
  9. data/lib/uniword/batch/operation.rb +22 -0
  10. data/lib/uniword/batch.rb +1 -0
  11. data/lib/uniword/caption/caption_builder.rb +120 -0
  12. data/lib/uniword/caption/counter.rb +61 -0
  13. data/lib/uniword/caption/cross_reference.rb +44 -0
  14. data/lib/uniword/caption.rb +24 -0
  15. data/lib/uniword/cli/main.rb +134 -0
  16. data/lib/uniword/cli/review_cli.rb +34 -0
  17. data/lib/uniword/configuration.rb +17 -0
  18. data/lib/uniword/diff/semantic/change.rb +66 -0
  19. data/lib/uniword/diff/semantic/engine.rb +31 -0
  20. data/lib/uniword/diff/semantic/paragraph_comparator.rb +195 -0
  21. data/lib/uniword/diff/semantic/result.rb +53 -0
  22. data/lib/uniword/diff/semantic.rb +26 -0
  23. data/lib/uniword/diff.rb +1 -0
  24. data/lib/uniword/docx/deterministic_output.rb +47 -0
  25. data/lib/uniword/docx.rb +1 -0
  26. data/lib/uniword/find_replace/body_scope.rb +23 -0
  27. data/lib/uniword/find_replace/comment_scope.rb +23 -0
  28. data/lib/uniword/find_replace/endnote_scope.rb +23 -0
  29. data/lib/uniword/find_replace/engine.rb +84 -0
  30. data/lib/uniword/find_replace/footer_scope.rb +24 -0
  31. data/lib/uniword/find_replace/footnote_scope.rb +23 -0
  32. data/lib/uniword/find_replace/header_scope.rb +24 -0
  33. data/lib/uniword/find_replace/matcher.rb +23 -0
  34. data/lib/uniword/find_replace/paragraph_walker.rb +61 -0
  35. data/lib/uniword/find_replace/regex_matcher.rb +38 -0
  36. data/lib/uniword/find_replace/result.rb +54 -0
  37. data/lib/uniword/find_replace/scope.rb +98 -0
  38. data/lib/uniword/find_replace/string_matcher.rb +72 -0
  39. data/lib/uniword/find_replace/styles_scope.rb +38 -0
  40. data/lib/uniword/find_replace.rb +29 -0
  41. data/lib/uniword/infrastructure/zip_packager.rb +28 -1
  42. data/lib/uniword/lint/builtin_rules/banned_words.rb +35 -0
  43. data/lib/uniword/lint/builtin_rules/max_paragraph_length.rb +32 -0
  44. data/lib/uniword/lint/builtin_rules/require_body.rb +23 -0
  45. data/lib/uniword/lint/builtin_rules/required_style.rb +31 -0
  46. data/lib/uniword/lint/builtin_rules.rb +23 -0
  47. data/lib/uniword/lint/engine.rb +32 -0
  48. data/lib/uniword/lint/result.rb +59 -0
  49. data/lib/uniword/lint/rule.rb +77 -0
  50. data/lib/uniword/lint/ruleset.rb +65 -0
  51. data/lib/uniword/lint.rb +17 -0
  52. data/lib/uniword/plugin/cli_command.rb +30 -0
  53. data/lib/uniword/plugin/loader.rb +43 -0
  54. data/lib/uniword/plugin/registry.rb +83 -0
  55. data/lib/uniword/plugin/transformer.rb +50 -0
  56. data/lib/uniword/plugin/validator.rb +34 -0
  57. data/lib/uniword/plugin.rb +25 -0
  58. data/lib/uniword/redact/engine.rb +52 -0
  59. data/lib/uniword/redact/pattern.rb +23 -0
  60. data/lib/uniword/redact/pattern_library.rb +60 -0
  61. data/lib/uniword/redact/result.rb +42 -0
  62. data/lib/uniword/redact.rb +15 -0
  63. data/lib/uniword/version.rb +1 -1
  64. data/lib/uniword/wordprocessingml/document_styling.rb +131 -0
  65. data/lib/uniword/wordprocessingml/settings.rb +2 -0
  66. data/lib/uniword/wordprocessingml/track_changes.rb +19 -0
  67. data/lib/uniword/wordprocessingml.rb +1 -0
  68. data/lib/uniword.rb +16 -0
  69. metadata +56 -2
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module Uniword
6
+ module Lint
7
+ module BuiltinRules
8
+ # Rule: paragraphs containing banned words trigger a finding.
9
+ class BannedWords < Rule
10
+ register :banned_words, self
11
+
12
+ # @param words [Array<String>] words to flag
13
+ def initialize(words:, **rest)
14
+ super(**rest)
15
+ @banned = Set.new(Array(words).map(&:downcase))
16
+ end
17
+
18
+ def check(document)
19
+ document.paragraphs.each_with_index do |paragraph, idx|
20
+ text = paragraph.text.to_s.downcase
21
+ @banned.each do |word|
22
+ next unless text.match?(/\b#{Regexp.escape(word)}\b/)
23
+
24
+ yield finding(
25
+ message: "Paragraph #{idx + 1} contains banned word " \
26
+ "'#{word}'",
27
+ path: "paragraph[#{idx}]",
28
+ )
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Lint
5
+ module BuiltinRules
6
+ # Rule: paragraphs longer than `max` words trigger a finding.
7
+ class MaxParagraphLength < Rule
8
+ register :max_paragraph_length, self
9
+
10
+ # @param max [Integer] word count threshold (default 200)
11
+ def initialize(max: 200, **rest)
12
+ super(**rest)
13
+ @max = max
14
+ end
15
+
16
+ def check(document)
17
+ document.paragraphs.each_with_index do |paragraph, idx|
18
+ text = paragraph.text.to_s
19
+ word_count = text.split.length
20
+ next if word_count <= @max
21
+
22
+ yield finding(
23
+ message: "Paragraph #{idx + 1} has #{word_count} words " \
24
+ "(max #{@max})",
25
+ path: "paragraph[#{idx}]",
26
+ )
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Lint
5
+ module BuiltinRules
6
+ # Rule: documents must have a non-empty body.
7
+ class RequireBody < Rule
8
+ register :require_body, self
9
+
10
+ def check(document)
11
+ body = document.body
12
+ paragraphs = body&.paragraphs || []
13
+ return if paragraphs.any?
14
+
15
+ yield finding(
16
+ message: "Document body is empty",
17
+ path: "word/document.xml",
18
+ )
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Lint
5
+ module BuiltinRules
6
+ # Rule: required style presence. Triggers when the named style
7
+ # is missing from the document.
8
+ class RequiredStyle < Rule
9
+ register :required_style, self
10
+
11
+ # @param style_id [String, Array<String>] required styleId(s)
12
+ def initialize(style_id:, **rest)
13
+ super(**rest)
14
+ @required = Array(style_id)
15
+ end
16
+
17
+ def check(document)
18
+ present = document.styles_configuration&.styles&.map(&:styleId) || []
19
+ @required.each do |id|
20
+ next if present.include?(id)
21
+
22
+ yield finding(
23
+ message: "Required style '#{id}' is missing",
24
+ path: "styles.xml",
25
+ )
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Lint
5
+ # Container module for builtin rule classes. Loaded eagerly by
6
+ # `Lint` so each class's `register` call fires.
7
+ module BuiltinRules
8
+ autoload :MaxParagraphLength,
9
+ "#{__dir__}/builtin_rules/max_paragraph_length"
10
+ autoload :BannedWords, "#{__dir__}/builtin_rules/banned_words"
11
+ autoload :RequiredStyle, "#{__dir__}/builtin_rules/required_style"
12
+ autoload :RequireBody, "#{__dir__}/builtin_rules/require_body"
13
+
14
+ # Touch every autoload to force-load + register each rule.
15
+ ALL = [
16
+ MaxParagraphLength,
17
+ BannedWords,
18
+ RequiredStyle,
19
+ RequireBody,
20
+ ].freeze
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Lint
5
+ # Walks the document with each rule and aggregates findings.
6
+ class Engine
7
+ # @param document [Wordprocessingml::DocumentRoot]
8
+ # @param ruleset [Ruleset, Array<Rule>] rules to apply
9
+ def initialize(document:, ruleset:)
10
+ @document = document
11
+ @ruleset = wrap_ruleset(ruleset)
12
+ end
13
+
14
+ # @return [Result]
15
+ def run
16
+ result = Result.new
17
+ @ruleset.rules.each do |rule|
18
+ rule.check(@document) { |finding| result.add(finding) }
19
+ end
20
+ result
21
+ end
22
+
23
+ private
24
+
25
+ def wrap_ruleset(ruleset)
26
+ return ruleset if ruleset.is_a?(Ruleset)
27
+
28
+ Ruleset.new(Array(ruleset))
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Lint
5
+ # Aggregated lint result with severity counts.
6
+ class Result
7
+ SEVERITY_ORDER = %i[error warning info].freeze
8
+
9
+ attr_reader :findings
10
+
11
+ def initialize
12
+ @findings = []
13
+ end
14
+
15
+ # @param finding [Hash]
16
+ # @return [void]
17
+ def add(finding)
18
+ @findings << finding
19
+ end
20
+
21
+ # True when at least one :error severity finding exists.
22
+ #
23
+ # @return [Boolean]
24
+ def errors?
25
+ @findings.any? { |f| f[:severity] == :error }
26
+ end
27
+
28
+ # Count by severity.
29
+ #
30
+ # @return [Hash{Symbol => Integer}]
31
+ def by_severity
32
+ @findings.group_by { |f| f[:severity] }
33
+ .transform_values(&:count)
34
+ end
35
+
36
+ # Count by rule name.
37
+ #
38
+ # @return [Hash{Symbol => Integer}]
39
+ def by_rule
40
+ @findings.group_by { |f| f[:rule] }
41
+ .transform_values(&:count)
42
+ end
43
+
44
+ # Total finding count.
45
+ #
46
+ # @return [Integer]
47
+ def count
48
+ @findings.length
49
+ end
50
+
51
+ # True when zero findings.
52
+ #
53
+ # @return [Boolean]
54
+ def empty?
55
+ @findings.empty?
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Lint
5
+ # Abstract base class for a single lint rule. Subclasses implement
6
+ # `#check` (yields findings) and declare `name`, `severity`.
7
+ #
8
+ # Open/closed: new rule type = new subclass + (optionally) a
9
+ # YAML schema extension. Engine unchanged.
10
+ class Rule
11
+ # Registered rule subclasses by type name. Constant on `Rule`
12
+ # itself (not subclasses) so `register` calls always reach the
13
+ # same hash. `Hash.new` (rather than `{}`) avoids the
14
+ # Style/MutableConstant autocorrect that would `.freeze` it and
15
+ # break `register`.
16
+ TYPES = Hash.new
17
+
18
+ attr_reader :name, :severity, :options
19
+
20
+ # @param name [Symbol] rule identifier
21
+ # @param severity [Symbol] :error, :warning, or :info
22
+ # @param options [Hash] rule-specific options
23
+ def initialize(name:, severity: :warning, **options)
24
+ @name = name
25
+ @severity = severity
26
+ @options = options
27
+ end
28
+
29
+ # Walk the document and yield each finding.
30
+ #
31
+ # @param document [Wordprocessingml::DocumentRoot]
32
+ # @yieldparam finding [Hash] { message:, path:, position: }
33
+ # @return [void]
34
+ def check(document)
35
+ raise NotImplementedError
36
+ end
37
+
38
+ # Build a finding hash.
39
+ #
40
+ # @param message [String]
41
+ # @param path [String, nil] document path (paragraph index,
42
+ # style id, etc.)
43
+ # @return [Hash]
44
+ def finding(message:, path: nil)
45
+ { rule: name, severity: severity, message: message, path: path }
46
+ end
47
+
48
+ class << self
49
+ # Register a subclass under a type name for YAML ruleset
50
+ # lookup. Always writes to `Rule::TYPES` regardless of the
51
+ # calling subclass.
52
+ #
53
+ # @param name [Symbol]
54
+ # @param klass [Class<Rule>]
55
+ # @return [void]
56
+ def register(name, klass)
57
+ Rule::TYPES[name] = klass
58
+ end
59
+
60
+ # Lookup a registered rule subclass by type name.
61
+ #
62
+ # @param name [Symbol]
63
+ # @return [Class<Rule>, nil]
64
+ def type(name)
65
+ Rule::TYPES[name]
66
+ end
67
+
68
+ # All registered rule types.
69
+ #
70
+ # @return [Hash{Symbol => Class<Rule>}]
71
+ def types
72
+ Rule::TYPES
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Uniword
6
+ module Lint
7
+ # Loads rules from a YAML ruleset or symbol registry. Each rule
8
+ # has a `type` (which `Rule` subclass to instantiate) and
9
+ # rule-specific options.
10
+ #
11
+ # Example YAML:
12
+ #
13
+ # rules:
14
+ # - type: sentence_case_headings
15
+ # severity: warning
16
+ # - type: max_paragraph_length
17
+ # severity: warning
18
+ # max: 200
19
+ # - type: banned_words
20
+ # severity: warning
21
+ # words: [really, very, basically]
22
+ class Ruleset
23
+ # @param rules [Array<Rule>]
24
+ def initialize(rules = [])
25
+ @rules = rules
26
+ end
27
+
28
+ # @return [Array<Rule>]
29
+ attr_reader :rules
30
+
31
+ # Load a ruleset from a YAML file.
32
+ #
33
+ # @param path [String]
34
+ # @return [Ruleset]
35
+ def self.load(path)
36
+ data = YAML.safe_load_file(path)
37
+ from_hash(data || {})
38
+ end
39
+
40
+ # Build a ruleset from a parsed YAML hash.
41
+ #
42
+ # @param data [Hash] `{"rules" => [{"type" => ..., ...}, ...]}`
43
+ # @return [Ruleset]
44
+ def self.from_hash(data)
45
+ rule_list = (data["rules"] || []).map { |spec| build_rule(spec) }
46
+ new(rule_list)
47
+ end
48
+
49
+ # Build one rule from a spec hash by type name.
50
+ #
51
+ # @param spec [Hash] `{"type" => "name", "severity" => ...}`
52
+ # @return [Rule]
53
+ def self.build_rule(spec)
54
+ type = spec["type"].to_sym
55
+ klass = Rule.types[type]
56
+ raise ArgumentError, "unknown rule type: #{spec['type']}" unless klass
57
+
58
+ kwargs = spec.transform_keys(&:to_sym)
59
+ kwargs.delete(:type)
60
+ kwargs[:name] = type unless kwargs.key?(:name)
61
+ klass.new(**kwargs)
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ # Document lint: enforce content + structure rules beyond OPC
5
+ # validity. Backed by a YAML ruleset; rules are open for extension
6
+ # via `Lint::Rule.register`.
7
+ module Lint
8
+ autoload :Engine, "#{__dir__}/lint/engine"
9
+ autoload :Ruleset, "#{__dir__}/lint/ruleset"
10
+ autoload :Rule, "#{__dir__}/lint/rule"
11
+ autoload :Result, "#{__dir__}/lint/result"
12
+ autoload :BuiltinRules, "#{__dir__}/lint/builtin_rules"
13
+
14
+ # Force-load the builtins so their `register` calls run.
15
+ BuiltinRules.to_s
16
+ end
17
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Plugin
5
+ # Base class for plugin-provided CLI commands. A plugin CLI
6
+ # command is a Thor subclass that the main CLI registers as a
7
+ # subcommand.
8
+ #
9
+ # Subclasses declare `subcommand_name` (the name CLI users type)
10
+ # and `description` (shown in `uniword help`).
11
+ class CliCommand
12
+ class << self
13
+ # @return [Symbol] subcommand name (e.g. :myplugin)
14
+ def subcommand_name
15
+ raise NotImplementedError
16
+ end
17
+
18
+ # @return [String] one-line description
19
+ def description
20
+ raise NotImplementedError
21
+ end
22
+
23
+ # @return [Class<Thor>] the Thor subclass to register
24
+ def thor_class
25
+ raise NotImplementedError
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Plugin
5
+ # Discovers plugins from installed gems. Each plugin ships a
6
+ # ruby file under `uniword/plugin/<name>.rb` that registers
7
+ # itself with `Plugin::Registry` on load.
8
+ class Loader
9
+ GLOB = "uniword/plugin/*.rb"
10
+
11
+ class << self
12
+ # Load every plugin file found in installed gems.
13
+ #
14
+ # @return [Array<String>] paths loaded
15
+ def load_all
16
+ Gem.find_files(GLOB).each { |path| require path }
17
+ end
18
+
19
+ # Run every registered transformer whose `stages` include
20
+ # `stage`, in registration order.
21
+ #
22
+ # @param document [Wordprocessingml::DocumentRoot]
23
+ # @param stage [Symbol] one of `Transformer::STAGES`
24
+ # @return [void]
25
+ def run_transformers(document:, stage:)
26
+ Registry.transformers.each_value do |transformer|
27
+ next unless transformer.applies_to?(stage)
28
+
29
+ transformer.transform(document)
30
+ end
31
+ end
32
+
33
+ # Yield every registered validator in registration order.
34
+ #
35
+ # @yieldparam validator [Plugin::Validator]
36
+ # @return [void]
37
+ def each_validator(&block)
38
+ Registry.validators.each_value(&block)
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Plugin
5
+ # Central registry for installed plugins. Each plugin registers
6
+ # its extension surface (validator, transformer, cli command) by
7
+ # name; lookup is by name or by class.
8
+ #
9
+ # Open/closed: new extension surfaces = new methods on this
10
+ # class. Existing entries untouched.
11
+ class Registry
12
+ @validators = {}
13
+ @transformers = {}
14
+ @cli_commands = {}
15
+
16
+ class << self
17
+ # @return [Hash{Symbol => Plugin::Validator}]
18
+ attr_reader :validators
19
+
20
+ # @return [Hash{Symbol => Plugin::Transformer}]
21
+ attr_reader :transformers
22
+
23
+ # @return [Hash{Symbol => Plugin::CliCommand}]
24
+ attr_reader :cli_commands
25
+
26
+ # Register a validator. Append-only; duplicate name raises.
27
+ #
28
+ # @param name [Symbol]
29
+ # @param validator [Plugin::Validator]
30
+ # @return [void]
31
+ def register_validator(name, validator)
32
+ raise ArgumentError, "name must be a Symbol" unless name.is_a?(Symbol)
33
+ unless validator.is_a?(Validator)
34
+ raise ArgumentError,
35
+ "validator must be a Validator"
36
+ end
37
+
38
+ @validators[name] = validator
39
+ end
40
+
41
+ # Register a transformer.
42
+ #
43
+ # @param name [Symbol]
44
+ # @param transformer [Plugin::Transformer]
45
+ # @return [void]
46
+ def register_transformer(name, transformer)
47
+ raise ArgumentError, "name must be a Symbol" unless name.is_a?(Symbol)
48
+ unless transformer.is_a?(Transformer)
49
+ raise ArgumentError,
50
+ "transformer must be a Transformer"
51
+ end
52
+
53
+ @transformers[name] = transformer
54
+ end
55
+
56
+ # Register a CLI command (a Thor subclass).
57
+ #
58
+ # @param name [Symbol]
59
+ # @param command_class [Class]
60
+ # @return [void]
61
+ def register_cli_command(name, command_class)
62
+ raise ArgumentError, "name must be a Symbol" unless name.is_a?(Symbol)
63
+ unless command_class.is_a?(Class)
64
+ raise ArgumentError,
65
+ "command_class must be a Class"
66
+ end
67
+
68
+ @cli_commands[name] = command_class
69
+ end
70
+
71
+ # Clear every registry. Used between tests and by
72
+ # `Configuration#reset!`.
73
+ #
74
+ # @return [void]
75
+ def clear
76
+ @validators.clear
77
+ @transformers.clear
78
+ @cli_commands.clear
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Plugin
5
+ # Base class for plugin-provided document transformers.
6
+ # Subclasses implement `#transform(document)` which mutates the
7
+ # document in place.
8
+ #
9
+ # Pipelines call transformers at defined stages; see
10
+ # `Plugin.transform_for_stage`.
11
+ class Transformer
12
+ STAGES = %i[after_load before_save after_reconcile].freeze
13
+
14
+ # @return [Symbol] transformer name
15
+ attr_reader :name
16
+
17
+ # @return [Array<Symbol>] stages when this transformer runs
18
+ attr_reader :stages
19
+
20
+ # @param name [Symbol]
21
+ # @param stages [Array<Symbol>, Symbol] one or more of
22
+ # STAGES; defaults to `:before_save`
23
+ def initialize(name:, stages: :before_save)
24
+ @name = name
25
+ @stages = Array(stages)
26
+ unknown = @stages - STAGES
27
+ return if unknown.empty?
28
+
29
+ raise ArgumentError,
30
+ "unknown stages: #{unknown.inspect}; valid: #{STAGES.inspect}"
31
+ end
32
+
33
+ # True when this transformer runs at the given stage.
34
+ #
35
+ # @param stage [Symbol]
36
+ # @return [Boolean]
37
+ def applies_to?(stage)
38
+ @stages.include?(stage)
39
+ end
40
+
41
+ # Mutate the document. Subclasses override.
42
+ #
43
+ # @param document [Wordprocessingml::DocumentRoot]
44
+ # @return [void]
45
+ def transform(document)
46
+ raise NotImplementedError
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Plugin
5
+ # Base class for plugin-provided validation rules. Subclasses
6
+ # implement `#check(document)` (yields findings).
7
+ #
8
+ # Open/closed: adding a new validator = subclassing + calling
9
+ # `Registry.register_validator`.
10
+ class Validator
11
+ # @return [Symbol] validator name (matches the registry key)
12
+ attr_reader :name
13
+
14
+ # @return [Symbol] default severity (:error, :warning, :info)
15
+ attr_reader :severity
16
+
17
+ # @param name [Symbol]
18
+ # @param severity [Symbol]
19
+ def initialize(name:, severity: :warning)
20
+ @name = name
21
+ @severity = severity
22
+ end
23
+
24
+ # Walk the document and yield each finding as a hash.
25
+ #
26
+ # @param document [Wordprocessingml::DocumentRoot]
27
+ # @yieldparam finding [Hash] { message:, path: }
28
+ # @return [void]
29
+ def check(document)
30
+ raise NotImplementedError
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ # Plugin system: extend uniword without forking.
5
+ #
6
+ # Three extension surfaces, each with a base class:
7
+ #
8
+ # - `Plugin::Validator` — register custom validation rules
9
+ # - `Plugin::Transformer` — mutate documents at defined pipeline
10
+ # stages (load, pre-save, post-reconcile)
11
+ # - `Plugin::CliCommand` — register new Thor subcommands
12
+ #
13
+ # Discovery: `Plugin::Loader.load_all` walks installed gems via
14
+ # `Gem.find_files("uniword/plugin/*.rb")` and loads each.
15
+ #
16
+ # Configuration: `Uniword.configuration.plugins = [:my_plugin]`
17
+ # (default `:all`).
18
+ module Plugin
19
+ autoload :Registry, "#{__dir__}/plugin/registry"
20
+ autoload :Validator, "#{__dir__}/plugin/validator"
21
+ autoload :Transformer, "#{__dir__}/plugin/transformer"
22
+ autoload :CliCommand, "#{__dir__}/plugin/cli_command"
23
+ autoload :Loader, "#{__dir__}/plugin/loader"
24
+ end
25
+ end