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.
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)
@@ -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
@@ -9,7 +9,7 @@ require 'pathname'
9
9
  require 'optparse'
10
10
 
11
11
  module AsciidocPubkit
12
- VERSION = '0.8.7'
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.8.7
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: Generate Japanese technical writing prompts, review AsciiDoc prose, section
41
- headings, and list text separately, verify protected manuscript content, and explicitly
42
- apply prose-only mechanical replacements.
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: Japanese writing and review tools for AsciiDoc books
110
+ summary: Document inspection, replacement, and Japanese review tools for AsciiDoc
111
+ books
95
112
  test_files: []