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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +54 -0
- data/lib/uniword/batch/operation/file_result.rb +39 -0
- data/lib/uniword/batch/operation/repair_task.rb +34 -0
- data/lib/uniword/batch/operation/report.rb +66 -0
- data/lib/uniword/batch/operation/runner.rb +30 -0
- data/lib/uniword/batch/operation/task.rb +40 -0
- data/lib/uniword/batch/operation/verify_task.rb +27 -0
- data/lib/uniword/batch/operation.rb +22 -0
- data/lib/uniword/batch.rb +1 -0
- data/lib/uniword/caption/caption_builder.rb +120 -0
- data/lib/uniword/caption/counter.rb +61 -0
- data/lib/uniword/caption/cross_reference.rb +44 -0
- data/lib/uniword/caption.rb +24 -0
- data/lib/uniword/cli/main.rb +134 -0
- data/lib/uniword/cli/review_cli.rb +34 -0
- data/lib/uniword/configuration.rb +17 -0
- data/lib/uniword/diff/semantic/change.rb +66 -0
- data/lib/uniword/diff/semantic/engine.rb +31 -0
- data/lib/uniword/diff/semantic/paragraph_comparator.rb +195 -0
- data/lib/uniword/diff/semantic/result.rb +53 -0
- data/lib/uniword/diff/semantic.rb +26 -0
- data/lib/uniword/diff.rb +1 -0
- data/lib/uniword/docx/deterministic_output.rb +47 -0
- data/lib/uniword/docx.rb +1 -0
- data/lib/uniword/find_replace/body_scope.rb +23 -0
- data/lib/uniword/find_replace/comment_scope.rb +23 -0
- data/lib/uniword/find_replace/endnote_scope.rb +23 -0
- data/lib/uniword/find_replace/engine.rb +84 -0
- data/lib/uniword/find_replace/footer_scope.rb +24 -0
- data/lib/uniword/find_replace/footnote_scope.rb +23 -0
- data/lib/uniword/find_replace/header_scope.rb +24 -0
- data/lib/uniword/find_replace/matcher.rb +23 -0
- data/lib/uniword/find_replace/paragraph_walker.rb +61 -0
- data/lib/uniword/find_replace/regex_matcher.rb +38 -0
- data/lib/uniword/find_replace/result.rb +54 -0
- data/lib/uniword/find_replace/scope.rb +98 -0
- data/lib/uniword/find_replace/string_matcher.rb +72 -0
- data/lib/uniword/find_replace/styles_scope.rb +38 -0
- data/lib/uniword/find_replace.rb +29 -0
- data/lib/uniword/infrastructure/zip_packager.rb +28 -1
- data/lib/uniword/lint/builtin_rules/banned_words.rb +35 -0
- data/lib/uniword/lint/builtin_rules/max_paragraph_length.rb +32 -0
- data/lib/uniword/lint/builtin_rules/require_body.rb +23 -0
- data/lib/uniword/lint/builtin_rules/required_style.rb +31 -0
- data/lib/uniword/lint/builtin_rules.rb +23 -0
- data/lib/uniword/lint/engine.rb +32 -0
- data/lib/uniword/lint/result.rb +59 -0
- data/lib/uniword/lint/rule.rb +77 -0
- data/lib/uniword/lint/ruleset.rb +65 -0
- data/lib/uniword/lint.rb +17 -0
- data/lib/uniword/plugin/cli_command.rb +30 -0
- data/lib/uniword/plugin/loader.rb +43 -0
- data/lib/uniword/plugin/registry.rb +83 -0
- data/lib/uniword/plugin/transformer.rb +50 -0
- data/lib/uniword/plugin/validator.rb +34 -0
- data/lib/uniword/plugin.rb +25 -0
- data/lib/uniword/redact/engine.rb +52 -0
- data/lib/uniword/redact/pattern.rb +23 -0
- data/lib/uniword/redact/pattern_library.rb +60 -0
- data/lib/uniword/redact/result.rb +42 -0
- data/lib/uniword/redact.rb +15 -0
- data/lib/uniword/version.rb +1 -1
- data/lib/uniword/wordprocessingml/document_styling.rb +131 -0
- data/lib/uniword/wordprocessingml/settings.rb +2 -0
- data/lib/uniword/wordprocessingml/track_changes.rb +19 -0
- data/lib/uniword/wordprocessingml.rb +1 -0
- data/lib/uniword.rb +16 -0
- 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
|
data/lib/uniword/lint.rb
ADDED
|
@@ -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
|