asciidoc-pubkit 0.8.7 → 1.0.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 +27 -0
- data/README.md +85 -1404
- data/docs/architecture.md +264 -0
- data/docs/configuration.md +48 -0
- data/docs/custom-rules.md +69 -0
- data/docs/development.md +138 -0
- data/docs/document-commands.md +68 -0
- data/docs/english-review-research.md +314 -0
- data/docs/installation.md +134 -0
- data/docs/list-review.md +50 -0
- data/docs/references.md +48 -0
- data/docs/replacements.md +193 -0
- data/docs/review-workflow.md +267 -0
- data/docs/rules-and-coverage.md +158 -0
- data/docs/scoring.md +37 -0
- data/docs/writing.md +24 -0
- data/lib/asciidoc_pubkit/cli.rb +8 -1
- data/lib/asciidoc_pubkit/document_commands.rb +215 -0
- data/lib/asciidoc_pubkit.rb +2 -1
- metadata +22 -5
data/docs/writing.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Writing commands
|
|
2
|
+
|
|
3
|
+
`writing criteria` prints the packaged common criteria. `writing prompt` adds
|
|
4
|
+
task instructions around those same criteria. Neither command needs an AsciiDoc
|
|
5
|
+
file, a review session, MeCab, or network access. Both accept `--lang ja` and
|
|
6
|
+
`--output FILE`; output defaults to stdout, and an existing output file is never
|
|
7
|
+
overwritten. The prompt asks the agent to read project instructions and evidence;
|
|
8
|
+
it does not supply source facts or authorize edits. Book-specific voice and
|
|
9
|
+
format still come from the book. The OSS-only prose style profile remains with
|
|
10
|
+
the book workflow, not the shared default.
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
asciidoc-pubkit writing criteria --lang ja
|
|
14
|
+
asciidoc-pubkit writing prompt --lang ja --output writing-prompt.md
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
For example, `--lang en` exits with an unsupported-language error. No English
|
|
18
|
+
review or writing criteria are shipped in 1.0.0.
|
|
19
|
+
|
|
20
|
+
For a small trial, use `examples/book.adoc` as the scan input. Its Japanese
|
|
21
|
+
paragraphs deliberately contain review candidates; its code block must remain
|
|
22
|
+
unchanged.
|
|
23
|
+
|
|
24
|
+
[Back to README](../README.md)
|
data/lib/asciidoc_pubkit/cli.rb
CHANGED
|
@@ -6,6 +6,10 @@ module AsciidocPubkit
|
|
|
6
6
|
Usage: asciidoc-pubkit <group> <command> [options] [input]
|
|
7
7
|
|
|
8
8
|
Commands:
|
|
9
|
+
document toc FILE Print the parsed section outline
|
|
10
|
+
document info FILE Extract book metadata
|
|
11
|
+
document check-xrefs FILE Report unresolved local cross-references
|
|
12
|
+
document index DIRECTORY Generate an English-title AsciiDoc index
|
|
9
13
|
writing criteria Print the shared Japanese prose criteria
|
|
10
14
|
writing prompt Generate a Japanese technical writing prompt
|
|
11
15
|
review scan FILE Collect Japanese prose, headings, or list text and review candidates
|
|
@@ -32,11 +36,14 @@ module AsciidocPubkit
|
|
|
32
36
|
out.puts VERSION
|
|
33
37
|
return 0
|
|
34
38
|
end
|
|
35
|
-
if args.empty? || args == ['--help'] || args == ['-h'] || %w[review writing replace].any? { |group| args == [group, '--help'] }
|
|
39
|
+
if args.empty? || args == ['--help'] || args == ['-h'] || %w[review writing replace document].any? { |group| args == [group, '--help'] }
|
|
36
40
|
out.puts HELP
|
|
37
41
|
return 0
|
|
38
42
|
end
|
|
39
43
|
group, command = args.shift(2)
|
|
44
|
+
if group == 'document' && %w[toc info check-xrefs index].include?(command)
|
|
45
|
+
return DocumentCommands.run(command, args, out: out, err: err)
|
|
46
|
+
end
|
|
40
47
|
valid = (group == 'review' && %w[scan score prompt verify].include?(command)) ||
|
|
41
48
|
(group == 'writing' && %w[criteria prompt].include?(command)) ||
|
|
42
49
|
(group == 'replace' && %w[check diff apply].include?(command))
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'find'
|
|
4
|
+
|
|
5
|
+
module AsciidocPubkit
|
|
6
|
+
# Read-only document utilities, independent of Japanese review settings.
|
|
7
|
+
class DocumentCommands
|
|
8
|
+
METADATA = %w[subtitle description keywords lang uuid author producer creator].freeze
|
|
9
|
+
|
|
10
|
+
def self.run(command, args, out:, err:)
|
|
11
|
+
options = { attributes: {} }
|
|
12
|
+
parser = OptionParser.new do |opts|
|
|
13
|
+
opts.banner = "Usage: asciidoc-pubkit document #{command} [options] #{command == 'index' ? 'DIRECTORY' : 'FILE'}"
|
|
14
|
+
opts.on('-o', '--output FILE', 'New output file (default: standard output)') { |v| options[:output] = v }
|
|
15
|
+
opts.on('--base-dir DIR', 'Local include boundary (default: input directory)') { |v| options[:base_dir] = v }
|
|
16
|
+
opts.on('-a', '--attribute NAME=VALUE', 'Set an Asciidoctor attribute; repeat as needed') do |v|
|
|
17
|
+
key, value = v.split('=', 2)
|
|
18
|
+
raise Error, 'Attribute name cannot be empty.' if key.empty?
|
|
19
|
+
raise Error, 'Remote includes are not supported.' if key == 'allow-uri-read'
|
|
20
|
+
options[:attributes][key] = value || ''
|
|
21
|
+
end
|
|
22
|
+
if command == 'toc'
|
|
23
|
+
opts.on('--depth N', 'Maximum section level (positive integer)') do |v|
|
|
24
|
+
raise Error, 'Depth must be a positive integer.' unless v.match?(/\A[1-9][0-9]*\z/)
|
|
25
|
+
options[:depth] = v.to_i
|
|
26
|
+
end
|
|
27
|
+
opts.on('-n', '--numbered', 'Output outline numbers such as 1-2') { options[:numbered] = true }
|
|
28
|
+
elsif command == 'index'
|
|
29
|
+
opts.on('-t', '--title TITLE', 'Prepend an AsciiDoc index title') { |v| options[:title] = v }
|
|
30
|
+
else
|
|
31
|
+
opts.on('--json', 'Output JSON') { options[:json] = true }
|
|
32
|
+
end
|
|
33
|
+
opts.on('-h', '--help', 'Show command help') { options[:help] = true }
|
|
34
|
+
end
|
|
35
|
+
parser.parse!(args)
|
|
36
|
+
if options[:help]
|
|
37
|
+
out.puts parser
|
|
38
|
+
return 0
|
|
39
|
+
end
|
|
40
|
+
raise Error, 'Exactly one input is required. Use --help.' unless args.length == 1
|
|
41
|
+
if options[:output] && (File.exist?(options[:output]) || File.symlink?(options[:output]))
|
|
42
|
+
raise Error, "Output already exists: #{options[:output]}"
|
|
43
|
+
end
|
|
44
|
+
tool = new(options, err)
|
|
45
|
+
code = 0
|
|
46
|
+
if command == 'index'
|
|
47
|
+
content = tool.index(args.first)
|
|
48
|
+
else
|
|
49
|
+
doc = tool.load(args.first)
|
|
50
|
+
case command
|
|
51
|
+
when 'toc'
|
|
52
|
+
content = tool.toc(doc)
|
|
53
|
+
when 'info'
|
|
54
|
+
metadata = { 'doctitle' => doc.doctitle(sanitize: true).to_s.strip }
|
|
55
|
+
METADATA.each { |key| metadata[key] = doc.attr(key).to_s.strip }
|
|
56
|
+
content = options[:json] ? JSON.pretty_generate(metadata) + "\n" : metadata.map { |k, v| "#{k}: #{v}\n" }.join
|
|
57
|
+
when 'check-xrefs'
|
|
58
|
+
missing = tool.check_xrefs(doc)
|
|
59
|
+
code = missing.empty? ? 0 : 1
|
|
60
|
+
content = options[:json] ? JSON.pretty_generate(missing) + "\n" : missing.map { |item| "#{item['file']}:#{item['line'] || '?'}\t#{item['refid']}\n" }.join
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
CLI.output(content, options[:output], out)
|
|
64
|
+
code
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def initialize(options, err)
|
|
68
|
+
@options, @err = options, err
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def load(file, default_root: nil)
|
|
72
|
+
entry = File.realpath(file)
|
|
73
|
+
root = File.realpath(@options[:base_dir] || default_root || File.dirname(entry))
|
|
74
|
+
raise Error, "Base directory is not a directory: #{root}" unless File.directory?(root)
|
|
75
|
+
check_source(entry, root)
|
|
76
|
+
logger = Asciidoctor::MemoryLogger.new
|
|
77
|
+
previous = Asciidoctor::LoggerManager.logger
|
|
78
|
+
begin
|
|
79
|
+
Asciidoctor::LoggerManager.logger = logger
|
|
80
|
+
doc = Asciidoctor.load_file(entry, safe: :safe, sourcemap: true, parse: false,
|
|
81
|
+
base_dir: root, attributes: @options[:attributes])
|
|
82
|
+
owner = self
|
|
83
|
+
# Reject targets before Asciidoctor can clamp an outside path into its
|
|
84
|
+
# safe-mode jail, and before reading any remote or symlinked source.
|
|
85
|
+
doc.reader.define_singleton_method(:resolve_include_path) do |target, attrlist, attributes|
|
|
86
|
+
raise Error, 'Remote includes are not supported.' if Asciidoctor::Helpers.uriish?(target)
|
|
87
|
+
candidate = File.expand_path(target, dir)
|
|
88
|
+
unless candidate.start_with?(root.end_with?('/') ? root : root + '/')
|
|
89
|
+
raise Error, "Include is outside the base directory: #{target}"
|
|
90
|
+
end
|
|
91
|
+
owner.check_source(candidate, root) if File.file?(candidate)
|
|
92
|
+
super(target, attrlist, attributes)
|
|
93
|
+
end
|
|
94
|
+
doc.reader.define_singleton_method(:push_include) do |*args|
|
|
95
|
+
owner.check_source(args[1], root) if args[1] && File.file?(args[1])
|
|
96
|
+
super(*args)
|
|
97
|
+
end
|
|
98
|
+
doc.parse
|
|
99
|
+
ensure
|
|
100
|
+
Asciidoctor::LoggerManager.logger = previous
|
|
101
|
+
end
|
|
102
|
+
messages = logger.messages
|
|
103
|
+
messages.each { |m| @err.puts "#{m[:severity]}: #{m[:message]}" }
|
|
104
|
+
raise Error, "Failed to parse document: #{entry}" if messages.any? { |m| %i[ERROR FATAL].include?(m[:severity]) }
|
|
105
|
+
doc
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def check_source(file, root)
|
|
109
|
+
path = File.realpath(file)
|
|
110
|
+
raise Error, "Source is outside the base directory: #{path}" unless path.start_with?(root.end_with?('/') ? root : root + '/')
|
|
111
|
+
AsciidocPubkit.read_text(path)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def toc(doc)
|
|
115
|
+
lines = []
|
|
116
|
+
walk = lambda do |sections, prefix, nesting|
|
|
117
|
+
sections.each_with_index do |section, index|
|
|
118
|
+
number = prefix + [index + 1]
|
|
119
|
+
if !@options[:depth] || section.level <= @options[:depth]
|
|
120
|
+
title = section.title
|
|
121
|
+
lines << (@options[:numbered] ? "#{number.join('-')}. #{title}" : "#{' ' * nesting}#{title}")
|
|
122
|
+
walk.call(section.sections, number, nesting + 1)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
walk.call(doc.sections, [], 0)
|
|
127
|
+
lines.empty? ? '' : lines.join("\n") + "\n"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def index(directory)
|
|
131
|
+
root = File.realpath(directory)
|
|
132
|
+
raise Error, "Input is not a directory: #{directory}" unless File.directory?(root)
|
|
133
|
+
entries = []
|
|
134
|
+
Find.find(root) do |path|
|
|
135
|
+
if File.directory?(path)
|
|
136
|
+
Find.prune if path != root && File.basename(path).start_with?('.')
|
|
137
|
+
next
|
|
138
|
+
end
|
|
139
|
+
next unless File.extname(path) == '.adoc'
|
|
140
|
+
next if @options[:output] && File.expand_path(path) == File.expand_path(@options[:output])
|
|
141
|
+
doc = load(path, default_root: root)
|
|
142
|
+
next if doc.id.to_s.empty?
|
|
143
|
+
title = doc.doctitle(sanitize: true).to_s.gsub(/\s*\([^)]*\)/, '').split(/[::]/, 2).first.to_s.gsub(/\s+/, ' ').strip
|
|
144
|
+
next unless title.ascii_only? && title.match?(/[A-Za-z]/)
|
|
145
|
+
entries << { id: doc.id, title: title, path: Pathname.new(path).relative_path_from(Pathname.new(root)).to_s }
|
|
146
|
+
end
|
|
147
|
+
selected = entries.sort_by { |e| [e[:path].count('/'), e[:path]] }.each_with_object({}) { |e, memo| memo[e[:title].downcase] ||= e }
|
|
148
|
+
lines = []
|
|
149
|
+
if @options[:title]
|
|
150
|
+
raise Error, 'Index title must be a nonempty single line.' if @options[:title].strip.empty? || @options[:title].match?(/[\r\n]/)
|
|
151
|
+
lines.concat(['[#book-index]', "= #{@options[:title]}"])
|
|
152
|
+
end
|
|
153
|
+
selected.values.sort_by { |e| [e[:title].downcase, e[:path]] }.group_by { |e| e[:title][/[A-Za-z]/].upcase }.each do |letter, group|
|
|
154
|
+
lines << '' unless lines.empty?
|
|
155
|
+
lines.concat(['[discrete]', "=== #{letter}"])
|
|
156
|
+
group.each { |e| lines << "* <<#{e[:id]},#{e[:title]}>>" }
|
|
157
|
+
end
|
|
158
|
+
lines.empty? ? '' : lines.join("\n") + "\n"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Scan raw AST fields only where macro substitutions are active. Converted
|
|
162
|
+
# text would hide xref targets; source-wide matching would include comments/code.
|
|
163
|
+
def check_xrefs(doc)
|
|
164
|
+
missing = []
|
|
165
|
+
nodes = doc.find_by.dup
|
|
166
|
+
nodes.select { |node| node.context == :table }.each do |table|
|
|
167
|
+
[table.rows.head, table.rows.body, table.rows.foot].flatten.each do |cell|
|
|
168
|
+
nodes << cell
|
|
169
|
+
nodes.concat(cell.inner_document.find_by) if cell.inner_document
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
nodes.each do |node|
|
|
173
|
+
cursor = node.source_location
|
|
174
|
+
if node.respond_to?(:subs) && node.subs.include?(:macros)
|
|
175
|
+
if node.respond_to?(:lines)
|
|
176
|
+
node.lines.each_with_index { |line, i| scan_refs(line, node, cursor, i, doc, missing) }
|
|
177
|
+
elsif node.instance_variable_defined?(:@text)
|
|
178
|
+
scan_refs(node.instance_variable_get(:@text), node, cursor, 0, doc, missing)
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
title = node.instance_variable_get(:@title)
|
|
182
|
+
scan_refs(title, node, cursor, 0, doc, missing) if title
|
|
183
|
+
end
|
|
184
|
+
missing.uniq { |m| m.values_at('file', 'line', 'refid') }.sort_by { |m| [m['file'], m['line'] || 0, m['refid']] }
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def scan_refs(text, node, cursor, offset, doc, missing)
|
|
188
|
+
text = node.apply_subs(text, [:attributes])
|
|
189
|
+
targets = text.scan(/(?<!\\)xref:([^\[]+)\[(?:\\.|[^\]\\])*\]/).map { |m| [m.first, :macro] }
|
|
190
|
+
targets.concat(text.scan(/(?<!\\)<<(.+?)>>/).map { |m| [m.first.split(',', 2).first, :short] })
|
|
191
|
+
targets.each do |target, kind|
|
|
192
|
+
target = target.strip
|
|
193
|
+
if target.include?('#')
|
|
194
|
+
path, id = target.split('#', 2)
|
|
195
|
+
next unless path.empty? || local_path?(path, doc, cursor)
|
|
196
|
+
else
|
|
197
|
+
next if kind == :macro && !File.extname(target).empty?
|
|
198
|
+
id = target
|
|
199
|
+
end
|
|
200
|
+
next if id.to_s.empty? || doc.catalog[:refs].key?(id) || doc.resolve_id(id)
|
|
201
|
+
missing << { 'refid' => id, 'file' => cursor&.file || doc.attr('docfile'), 'line' => cursor && cursor.lineno + offset }
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def local_path?(path, doc, cursor)
|
|
206
|
+
return false if path.match?(/\A[a-z][a-z0-9+.-]*:/i)
|
|
207
|
+
normalized = path.sub(/\.(?:adoc|asciidoc|asc|ad)\z/, '')
|
|
208
|
+
files = [doc.attr('docfile')] + doc.catalog[:includes].keys
|
|
209
|
+
files.any? do |file|
|
|
210
|
+
candidate = File.expand_path(file, doc.base_dir).sub(/\.(?:adoc|asciidoc|asc|ad)\z/, '')
|
|
211
|
+
[doc.base_dir, cursor&.file && File.dirname(cursor.file)].compact.any? { |base| candidate == File.expand_path(normalized, base) }
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
data/lib/asciidoc_pubkit.rb
CHANGED
|
@@ -9,7 +9,7 @@ require 'pathname'
|
|
|
9
9
|
require 'optparse'
|
|
10
10
|
|
|
11
11
|
module AsciidocPubkit
|
|
12
|
-
VERSION = '0.
|
|
12
|
+
VERSION = '1.0.0'
|
|
13
13
|
class Error < StandardError; end
|
|
14
14
|
|
|
15
15
|
def self.hash_text(text)
|
|
@@ -35,4 +35,5 @@ require_relative 'asciidoc_pubkit/session'
|
|
|
35
35
|
require_relative 'asciidoc_pubkit/local_evaluator'
|
|
36
36
|
require_relative 'asciidoc_pubkit/score'
|
|
37
37
|
require_relative 'asciidoc_pubkit/replacement'
|
|
38
|
+
require_relative 'asciidoc_pubkit/document_commands'
|
|
38
39
|
require_relative 'asciidoc_pubkit/cli'
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: asciidoc-pubkit
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 1.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- CyberGarage
|
|
@@ -37,9 +37,10 @@ dependencies:
|
|
|
37
37
|
- - "~>"
|
|
38
38
|
- !ruby/object:Gem::Version
|
|
39
39
|
version: '1.0'
|
|
40
|
-
description:
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
description: Inspect AsciiDoc outlines, book metadata, and local cross-references,
|
|
41
|
+
generate English document-title indexes, and generate Japanese technical writing
|
|
42
|
+
prompts, review AsciiDoc prose, section headings, and list text separately, verify
|
|
43
|
+
protected manuscript content, and explicitly apply prose-only mechanical replacements.
|
|
43
44
|
executables:
|
|
44
45
|
- asciidoc-pubkit
|
|
45
46
|
extensions: []
|
|
@@ -52,12 +53,27 @@ files:
|
|
|
52
53
|
- data/review-rules.ja.yml
|
|
53
54
|
- data/writing/ja/criteria.md
|
|
54
55
|
- data/writing/ja/headings.md
|
|
56
|
+
- docs/architecture.md
|
|
57
|
+
- docs/configuration.md
|
|
58
|
+
- docs/custom-rules.md
|
|
59
|
+
- docs/development.md
|
|
60
|
+
- docs/document-commands.md
|
|
61
|
+
- docs/english-review-research.md
|
|
62
|
+
- docs/installation.md
|
|
63
|
+
- docs/list-review.md
|
|
64
|
+
- docs/references.md
|
|
65
|
+
- docs/replacements.md
|
|
66
|
+
- docs/review-workflow.md
|
|
67
|
+
- docs/rules-and-coverage.md
|
|
68
|
+
- docs/scoring.md
|
|
69
|
+
- docs/writing.md
|
|
55
70
|
- examples/book.adoc
|
|
56
71
|
- examples/replacements.yml
|
|
57
72
|
- exe/asciidoc-pubkit
|
|
58
73
|
- lib/asciidoc_pubkit.rb
|
|
59
74
|
- lib/asciidoc_pubkit/cli.rb
|
|
60
75
|
- lib/asciidoc_pubkit/document.rb
|
|
76
|
+
- lib/asciidoc_pubkit/document_commands.rb
|
|
61
77
|
- lib/asciidoc_pubkit/heading_rules.rb
|
|
62
78
|
- lib/asciidoc_pubkit/language.rb
|
|
63
79
|
- lib/asciidoc_pubkit/local_evaluator.rb
|
|
@@ -91,5 +107,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
91
107
|
requirements: []
|
|
92
108
|
rubygems_version: 4.0.20
|
|
93
109
|
specification_version: 4
|
|
94
|
-
summary:
|
|
110
|
+
summary: Document inspection, replacement, and Japanese review tools for AsciiDoc
|
|
111
|
+
books
|
|
95
112
|
test_files: []
|