# frozen_string_literal: true
module Asciidoctor
# Public: Methods to perform substitutions on lines of AsciiDoc text. This module
# is intended to be mixed-in to Section and Block to provide operations for performing
# the necessary substitutions.
module Substitutors
SpecialCharsRx = /[<&>]/
SpecialCharsTr = { '>' => '>', '<' => '<', '&' => '&' }
# Detects if text is a possible candidate for the quotes substitution.
QuotedTextSniffRx = { false => /[*_`#^~]/, true => /[*'_+#^~]/ }
(BASIC_SUBS = [:specialcharacters]).freeze
(HEADER_SUBS = [:specialcharacters, :attributes]).freeze
(NO_SUBS = []).freeze
(NORMAL_SUBS = [:specialcharacters, :quotes, :attributes, :replacements, :macros, :post_replacements]).freeze
(REFTEXT_SUBS = [:specialcharacters, :quotes, :replacements]).freeze
(VERBATIM_SUBS = [:specialcharacters, :callouts]).freeze
SUB_GROUPS = {
none: NO_SUBS,
normal: NORMAL_SUBS,
verbatim: VERBATIM_SUBS,
specialchars: BASIC_SUBS,
}
SUB_HINTS = {
a: :attributes,
m: :macros,
n: :normal,
p: :post_replacements,
q: :quotes,
r: :replacements,
c: :specialcharacters,
v: :verbatim,
}
SUB_OPTIONS = {
block: SUB_GROUPS.keys + NORMAL_SUBS + [:callouts],
inline: SUB_GROUPS.keys + NORMAL_SUBS,
}
CAN = ?\u0018
DEL = ?\u007f
# Delimiters and matchers for the passthrough placeholder
# See http://www.aivosto.com/vbtips/control-characters.html#listabout for characters to use
# SPA, start of guarded protected area (\u0096)
PASS_START = ?\u0096
# EPA, end of guarded protected area (\u0097)
PASS_END = ?\u0097
# match passthrough slot
PassSlotRx = /#{PASS_START}(\d+)#{PASS_END}/
# fix passthrough slot after syntax highlighting
HighlightedPassSlotRx = %r(]*>#{PASS_START}[^\d]*(\d+)[^\d]*]*>#{PASS_END})
RS = '\\'
R_SB = ']'
ESC_R_SB = '\]'
PLUS = '+'
# Public: Apply the specified substitutions to the text.
#
# text - The String or String Array of text to process; must not be nil.
# subs - The substitutions to perform; must be a Symbol Array or nil (default: NORMAL_SUBS).
#
# Returns a String or String Array to match the type of the text argument with substitutions applied.
def apply_subs text, subs = NORMAL_SUBS
return text if text.empty? || !subs
if (is_multiline = ::Array === text)
text = text[1] ? (text.join LF) : text[0]
end
if subs.include? :macros
text = extract_passthroughs text
unless @passthroughs.empty?
passthrus = @passthroughs
# NOTE placeholders can move around, so we can only clear in the outermost substitution call
@passthroughs_locked ||= (clear_passthrus = true)
end
end
subs.each do |type|
case type
when :specialcharacters
text = sub_specialchars text
when :quotes
text = sub_quotes text
when :attributes
text = sub_attributes text if text.include? ATTR_REF_HEAD
when :replacements
text = sub_replacements text
when :macros
text = sub_macros text
when :highlight
text = highlight_source text, (subs.include? :callouts)
when :callouts
text = sub_callouts text unless subs.include? :highlight
when :post_replacements
text = sub_post_replacements text
else
logger.warn %(unknown substitution type #{type})
end
end
if passthrus
text = restore_passthroughs text
if clear_passthrus
passthrus.clear
@passthroughs_locked = nil
end
end
is_multiline ? (text.split LF, -1) : text
end
# Public: Apply normal substitutions.
#
# An alias for apply_subs with default remaining arguments.
#
# text - The String text to which to apply normal substitutions
#
# Returns the String with normal substitutions applied.
def apply_normal_subs text
apply_subs text, NORMAL_SUBS
end
# Public: Apply substitutions for header metadata and attribute assignments
#
# text - String containing the text process
#
# Returns A String with header substitutions performed
def apply_header_subs text
apply_subs text, HEADER_SUBS
end
# Public: Apply substitutions for titles.
#
# title - The String title to process
#
# Returns A String with title substitutions performed
alias apply_title_subs apply_subs
# Public: Apply substitutions for reftext.
#
# text - The String to process
#
# Returns a String with all substitutions from the reftext substitution group applied
def apply_reftext_subs text
apply_subs text, REFTEXT_SUBS
end
# Public: Substitute special characters (i.e., encode XML)
#
# The special characters <, &, and > get replaced with <, &, and >, respectively.
#
# text - The String text to process.
#
# Returns The String text with special characters replaced.
if RUBY_ENGINE == 'opal'
def sub_specialchars text
(text.include? ?>) || (text.include? ?&) || (text.include? ?<) ? (text.gsub SpecialCharsRx, SpecialCharsTr) : text
end
else
CGI = ::CGI
def sub_specialchars text
if (text.include? ?>) || (text.include? ?&) || (text.include? ?<)
(text.include? ?') || (text.include? ?") ? (text.gsub SpecialCharsRx, SpecialCharsTr) : (CGI.escape_html text)
else
text
end
end
end
alias sub_specialcharacters sub_specialchars
# Public: Substitute quoted text (includes emphasis, strong, monospaced, etc.)
#
# text - The String text to process
#
# returns The converted [String] text
def sub_quotes text
if QuotedTextSniffRx[compat = @document.compat_mode].match? text
QUOTE_SUBS[compat].each do |type, scope, pattern|
text = text.gsub(pattern) { convert_quoted_text $~, type, scope }
end
end
text
end
# Public: Substitutes attribute references in the specified text
#
# Attribute references are in the format +{name}+.
#
# If an attribute referenced in the line is missing or undefined, the line may be dropped
# based on the attribute-missing or attribute-undefined setting, respectively.
#
# text - The String text to process
# opts - A Hash of options to control processing: (default: {})
# * :attribute_missing controls how to handle a missing attribute (see Compliance.attribute_missing for values)
# * :drop_line_severity the severity level at which to log a dropped line (:info or :ignore)
#
# Returns the [String] text with the attribute references replaced with resolved values
def sub_attributes text, opts = {}
doc_attrs = @document.attributes
drop = drop_line = drop_line_severity = drop_empty_line = attribute_undefined = attribute_missing = nil
text = text.gsub AttributeReferenceRx do
# escaped attribute, return unescaped
if $1 == RS || $4 == RS
%({#{$2}})
elsif $3
case (args = $2.split ':', 3).shift
when 'set'
_, value = Parser.store_attribute args[0], args[1] || '', @document
# NOTE since this is an assignment, only drop-line applies here (skip and drop imply the same result)
if value || (attribute_undefined ||= (doc_attrs['attribute-undefined'] || Compliance.attribute_undefined)) != 'drop-line'
drop = drop_empty_line = DEL
else
drop = drop_line = CAN
end
when 'counter2'
@document.counter(*args)
drop = drop_empty_line = DEL
else # 'counter'
@document.counter(*args)
end
elsif doc_attrs.key?(key = $2.downcase)
doc_attrs[key]
elsif (value = INTRINSIC_ATTRIBUTES[key])
value
else
case (attribute_missing ||= (opts[:attribute_missing] || doc_attrs['attribute-missing'] || Compliance.attribute_missing))
when 'drop'
drop = drop_empty_line = DEL
when 'drop-line'
if (drop_line_severity ||= (opts[:drop_line_severity] || :info)) == :info
logger.info { %(dropping line containing reference to missing attribute: #{key}) }
#elsif drop_line_severity == :warn
# logger.warn %(dropping line containing reference to missing attribute: #{key})
end
drop = drop_line = CAN
when 'warn'
logger.warn %(skipping reference to missing attribute: #{key})
$&
else # 'skip'
$&
end
end
end
if drop
# drop lines from text
if drop_empty_line
lines = (text.squeeze DEL).split LF, -1
if drop_line
(lines.reject {|line| line == DEL || line == CAN || (line.start_with? CAN) || (line.include? CAN) }.join LF).delete DEL
else
(lines.reject {|line| line == DEL }.join LF).delete DEL
end
elsif text.include? LF
(text.split LF, -1).reject {|line| line == CAN || (line.start_with? CAN) || (line.include? CAN) }.join LF
else
''
end
else
text
end
end
# Public: Substitute replacement characters (e.g., copyright, trademark, etc.)
#
# text - The String text to process
#
# returns The [String] text with the replacement characters substituted
def sub_replacements text
REPLACEMENTS.each do |pattern, replacement, restore|
text = text.gsub(pattern) { do_replacement $~, replacement, restore }
end if ReplaceableTextRx.match? text
text
end
# Public: Substitute inline macros (e.g., links, images, etc)
#
# Replace inline macros, which may span multiple lines, in the provided text
#
# source - The String text to process
#
# returns The converted String text
def sub_macros text
#return text if text.nil_or_empty?
# some look ahead assertions to cut unnecessary regex calls
found_square_bracket = text.include? '['
found_colon = text.include? ':'
found_macroish = found_square_bracket && found_colon
found_macroish_short = found_macroish && (text.include? ':[')
doc_attrs = (doc = @document).attributes
# TODO allow position of substitution to be controlled (before or after other macros)
# TODO this handling needs some cleanup
if (extensions = doc.extensions) && extensions.inline_macros? # && found_macroish
extensions.inline_macros.each do |extension|
text = text.gsub extension.instance.regexp do
# honor the escape
next $&.slice 1, $&.length if (match = $&).start_with? RS
if $~.names.empty?
target, content = $1, $2
else
target, content = ($~[:target] rescue nil), ($~[:content] rescue nil)
end
attributes = (default_attrs = (ext_config = extension.config)[:default_attrs]) ? default_attrs.merge : {}
if content
if content.empty?
attributes['text'] = content unless ext_config[:content_model] == :attributes
else
content = normalize_text content, true, true
# QUESTION should we store the unparsed attrlist in the attrlist key?
if ext_config[:content_model] == :attributes
parse_attributes content, ext_config[:positional_attrs] || ext_config[:pos_attrs] || [], into: attributes
else
attributes['text'] = content
end
end
# NOTE for convenience, map content (unparsed attrlist) to target when format is short
target ||= ext_config[:format] == :short ? content : target
end
if Inline === (replacement = extension.process_method[self, target, attributes])
if (inline_subs = replacement.attributes.delete 'subs') && (inline_subs = expand_subs inline_subs, 'custom inline macro')
replacement.text = apply_subs replacement.text, inline_subs
end
replacement.convert
elsif replacement
logger.info { %(expected substitution value for custom inline macro to be of type Inline; got #{replacement.class}: #{match}) }
replacement
else
''
end
end
end
end
if doc_attrs.key? 'experimental'
if found_macroish_short && ((text.include? 'kbd:') || (text.include? 'btn:'))
text = text.gsub InlineKbdBtnMacroRx do
# honor the escape
if $1
$&.slice 1, $&.length
elsif $2 == 'kbd'
if (keys = $3.strip).include? R_SB
keys = keys.gsub ESC_R_SB, R_SB
end
if keys.length > 1 && (delim_idx = (delim_idx = keys.index ',', 1) ?
[delim_idx, (keys.index '+', 1)].compact.min : (keys.index '+', 1))
delim = keys.slice delim_idx, 1
# NOTE handle special case where keys ends with delimiter (e.g., Ctrl++ or Ctrl,,)
if keys.end_with? delim
keys = (keys.chop.split delim, -1).map {|key| key.strip }
keys[-1] += delim
else
keys = keys.split(delim).map {|key| key.strip }
end
else
keys = [keys]
end
(Inline.new self, :kbd, nil, attributes: { 'keys' => keys }).convert
else # $2 == 'btn'
(Inline.new self, :button, (normalize_text $3, true, true)).convert
end
end
end
if found_macroish && (text.include? 'menu:')
text = text.gsub InlineMenuMacroRx do
# honor the escape
next $&.slice 1, $&.length if $&.start_with? RS
menu = $1
if (items = $2)
items = items.gsub ESC_R_SB, R_SB if items.include? R_SB
if (delim = items.include?('>') ? '>' : (items.include?(',') ? ',' : nil))
submenus = items.split(delim).map {|it| it.strip }
menuitem = submenus.pop
else
submenus, menuitem = [], items.rstrip
end
else
submenus, menuitem = [], nil
end
Inline.new(self, :menu, nil, attributes: { 'menu' => menu, 'submenus' => submenus, 'menuitem' => menuitem }).convert
end
end
if (text.include? '"') && (text.include? '>')
text = text.gsub InlineMenuRx do
# honor the escape
next $&.slice 1, $&.length if $&.start_with? RS
menu, *submenus = $1.split('>').map {|it| it.strip }
menuitem = submenus.pop
Inline.new(self, :menu, nil, attributes: { 'menu' => menu, 'submenus' => submenus, 'menuitem' => menuitem }).convert
end
end
end
if found_macroish && ((text.include? 'image:') || (text.include? 'icon:'))
# image:filename.png[Alt Text]
text = text.gsub InlineImageMacroRx do
# honor the escape
if $&.start_with? RS
next $&.slice 1, $&.length
elsif $&.start_with? 'icon:'
type, posattrs = 'icon', ['size']
else
type, posattrs = 'image', ['alt', 'width', 'height']
end
target = $1
attrs = parse_attributes $2, posattrs, unescape_input: true
unless type == 'icon'
doc.register :images, target
attrs['imagesdir'] = doc_attrs['imagesdir']
end
attrs['alt'] ||= (attrs['default-alt'] = Helpers.basename(target, true).tr('_-', ' '))
Inline.new(self, :image, nil, type: type, target: target, attributes: attrs).convert
end
end
if ((text.include? '((') && (text.include? '))')) || (found_macroish_short && (text.include? 'dexterm'))
# (((Tigers,Big cats)))
# indexterm:[Tigers,Big cats]
# ((Tigers))
# indexterm2:[Tigers]
text = text.gsub InlineIndextermMacroRx do
case $1
when 'indexterm'
# honor the escape
next $&.slice 1, $&.length if $&.start_with? RS
# indexterm:[Tigers,Big cats]
if (attrlist = normalize_text $2, true, true).include? '='
if (primary = (attrs = (AttributeList.new attrlist, self).parse)[1])
attrs['terms'] = [primary]
if (see_also = attrs['see-also'])
attrs['see-also'] = (see_also.include? ',') ? (see_also.split ',').map {|it| it.lstrip } : [see_also]
end
else
attrs = { 'terms' => attrlist }
end
else
attrs = { 'terms' => (split_simple_csv attrlist) }
end
(Inline.new self, :indexterm, nil, attributes: attrs).convert
when 'indexterm2'
# honor the escape
next $&.slice 1, $&.length if $&.start_with? RS
# indexterm2:[Tigers]
if (term = normalize_text $2, true, true).include? '='
term = (attrs = (AttributeList.new term, self).parse)[1] || (attrs = nil) || term
if attrs && (see_also = attrs['see-also'])
attrs['see-also'] = (see_also.include? ',') ? (see_also.split ',').map {|it| it.lstrip } : [see_also]
end
end
(Inline.new self, :indexterm, term, attributes: attrs, type: :visible).convert
else
encl_text = $3
# honor the escape
if $&.start_with? RS
# escape concealed index term, but process nested flow index term
if (encl_text.start_with? '(') && (encl_text.end_with? ')')
encl_text = encl_text.slice 1, encl_text.length - 2
visible, before, after = true, '(', ')'
else
next $&.slice 1, $&.length
end
else
visible = true
if encl_text.start_with? '('
if encl_text.end_with? ')'
encl_text, visible = (encl_text.slice 1, encl_text.length - 2), false
else
encl_text, before, after = (encl_text.slice 1, encl_text.length), '(', ''
end
elsif encl_text.end_with? ')'
encl_text, before, after = encl_text.chop, '', ')'
end
end
if visible
# ((Tigers))
if (term = normalize_text encl_text, true).include? ';&'
if term.include? ' >> '
term, _, see = term.partition ' >> '
attrs = { 'see' => see }
elsif term.include? ' &> '
term, *see_also = term.split ' &> '
attrs = { 'see-also' => see_also }
end
end
subbed_term = (Inline.new self, :indexterm, term, attributes: attrs, type: :visible).convert
else
# (((Tigers,Big cats)))
attrs = {}
if (terms = normalize_text encl_text, true).include? ';&'
if terms.include? ' >> '
terms, _, see = terms.partition ' >> '
attrs['see'] = see
elsif terms.include? ' &> '
terms, *see_also = terms.split ' &> '
attrs['see-also'] = see_also
end
end
attrs['terms'] = split_simple_csv terms
subbed_term = (Inline.new self, :indexterm, nil, attributes: attrs).convert
end
before ? %(#{before}#{subbed_term}#{after}) : subbed_term
end
end
end
if found_colon && (text.include? '://')
# inline urls, target[text] (optionally prefixed with link: and optionally surrounded by <>)
text = text.gsub InlineLinkRx do
if (target = $2).start_with? RS
# honor the escape
next %(#{$1}#{target.slice 1, target.length}#{$4})
end
prefix, suffix = $1, ''
# NOTE if $4 is set, we're looking at a formal macro (e.g., https://example.org[])
if $4
prefix = '' if prefix == 'link:'
link_text = nil if (link_text = $4).empty?
else
# invalid macro syntax (link: prefix w/o trailing square brackets or enclosed in double quotes)
# FIXME we probably shouldn't even get here when the link: prefix is present; the regex is doing too much
case prefix
when 'link:', ?", ?'
next $&
end
case $3
when ')', '?', '!'
target = target.chop
if (suffix = $3) == ')' && (target.end_with? '.', '?', '!')
suffix = target[-1] + suffix
target = target.chop
end
# NOTE handle case when modified target is a URI scheme (e.g., http://)
next $& if target.end_with? '://'
when ';'
if (prefix.start_with? '<') && (target.end_with? '>')
# move surrounding <> out of URL
prefix = prefix.slice 4, prefix.length
target = target.slice 0, target.length - 4
elsif (target = target.chop).end_with? ')'
# move trailing ); out of URL
target = target.chop
suffix = ');'
else
# move trailing ; out of URL
suffix = ';'
end
# NOTE handle case when modified target is a URI scheme (e.g., http://)
next $& if target.end_with? '://'
when ':'
if (target = target.chop).end_with? ')'
# move trailing ): out of URL
target = target.chop
suffix = '):'
else
# move trailing : out of URL
suffix = ':'
end
# NOTE handle case when modified target is a URI scheme (e.g., http://)
next $& if target.end_with? '://'
end
end
attrs, link_opts = nil, { type: :link }
if link_text
new_link_text = link_text = link_text.gsub ESC_R_SB, R_SB if link_text.include? R_SB
if !doc.compat_mode && (link_text.include? '=')
# NOTE if an equals sign (=) is present, extract attributes from link text
link_text, attrs = extract_attributes_from_text link_text, ''
new_link_text = link_text
link_opts[:id] = attrs['id']
end
if link_text.end_with? '^'
new_link_text = link_text = link_text.chop
if attrs
attrs['window'] ||= '_blank'
else
attrs = { 'window' => '_blank' }
end
end
if new_link_text && new_link_text.empty?
# NOTE it's not possible for the URI scheme to be bare in this case
link_text = (doc_attrs.key? 'hide-uri-scheme') ? (target.sub UriSniffRx, '') : target
bare = true
end
else
# NOTE it's not possible for the URI scheme to be bare in this case
link_text = (doc_attrs.key? 'hide-uri-scheme') ? (target.sub UriSniffRx, '') : target
bare = true
end
if bare
if attrs
attrs['role'] = (attrs.key? 'role') ? %(bare #{attrs['role']}) : 'bare'
else
attrs = { 'role' => 'bare' }
end
end
doc.register :links, (link_opts[:target] = target)
link_opts[:attributes] = attrs if attrs
%(#{prefix}#{(Inline.new self, :anchor, link_text, link_opts).convert}#{suffix})
end
end
if found_macroish && ((text.include? 'link:') || (text.include? 'ilto:'))
# inline link macros, link:target[text]
text = text.gsub InlineLinkMacroRx do
# honor the escape
if $&.start_with? RS
next $&.slice 1, $&.length
elsif (mailto = $1)
target = 'mailto:' + (mailto_text = $2)
else
target = $2
end
attrs, link_opts = nil, { type: :link }
unless (link_text = $3).empty?
link_text = link_text.gsub ESC_R_SB, R_SB if link_text.include? R_SB
if mailto
if !doc.compat_mode && (link_text.include? ',')
# NOTE if a comma (,) is present, extract attributes from link text
link_text, attrs = extract_attributes_from_text link_text, ''
link_opts[:id] = attrs['id']
if attrs.key? 2
if attrs.key? 3
target = %(#{target}?subject=#{Helpers.encode_uri_component attrs[2]}&body=#{Helpers.encode_uri_component attrs[3]})
else
target = %(#{target}?subject=#{Helpers.encode_uri_component attrs[2]})
end
end
end
elsif !doc.compat_mode && (link_text.include? '=')
# NOTE if an equals sign (=) is present, extract attributes from link text
link_text, attrs = extract_attributes_from_text link_text, ''
link_opts[:id] = attrs['id']
end
if link_text.end_with? '^'
link_text = link_text.chop
if attrs
attrs['window'] ||= '_blank'
else
attrs = { 'window' => '_blank' }
end
end
end
if link_text.empty?
# mailto is a special case, already processed
if mailto
link_text = mailto_text
else
if doc_attrs.key? 'hide-uri-scheme'
if (link_text = target.sub UriSniffRx, '').empty?
link_text = target
end
else
link_text = target
end
if attrs
attrs['role'] = (attrs.key? 'role') ? %(bare #{attrs['role']}) : 'bare'
else
attrs = { 'role' => 'bare' }
end
end
end
# QUESTION should a mailto be registered as an e-mail address?
doc.register :links, (link_opts[:target] = target)
link_opts[:attributes] = attrs if attrs
Inline.new(self, :anchor, link_text, link_opts).convert
end
end
if text.include? '@'
text = text.gsub InlineEmailRx do
# honor the escape
next $1 == RS ? ($&.slice 1, $&.length) : $& if $1
target = 'mailto:' + (address = $&)
# QUESTION should this be registered as an e-mail address?
doc.register(:links, target)
Inline.new(self, :anchor, address, type: :link, target: target).convert
end
end
if found_square_bracket && @context == :list_item && @parent.style == 'bibliography'
text = text.sub(InlineBiblioAnchorRx) { (Inline.new self, :anchor, $2, type: :bibref, id: $1).convert }
end
if (found_square_bracket && text.include?('[[')) || (found_macroish && text.include?('or:'))
text = text.gsub InlineAnchorRx do
# honor the escape
next $&.slice 1, $&.length if $1
# NOTE reftext is only relevant for DocBook output; used as value of xreflabel attribute
if (id = $2)
reftext = $3
else
id = $4
if (reftext = $5) && (reftext.include? R_SB)
reftext = reftext.gsub ESC_R_SB, R_SB
end
end
Inline.new(self, :anchor, reftext, type: :ref, id: id).convert
end
end
#if (text.include? ';&l') || (found_macroish && (text.include? 'xref:'))
if ((text.include? '&') && (text.include? ';&l')) || (found_macroish && (text.include? 'xref:'))
text = text.gsub InlineXrefMacroRx do
# honor the escape
next $&.slice 1, $&.length if $&.start_with? RS
attrs = {}
if (refid = $1)
if refid.include? ','
refid, _, link_text = refid.partition ','
link_text = nil if (link_text = link_text.lstrip).empty?
end
else
macro = true
refid = $2
if (link_text = $3)
link_text = link_text.gsub ESC_R_SB, R_SB if link_text.include? R_SB
# NOTE if an equals sign (=) is present, extract attributes from link text
link_text, attrs = extract_attributes_from_text link_text if !doc.compat_mode && (link_text.include? '=')
end
end
if doc.compat_mode
fragment = refid
elsif (hash_idx = refid.index '#')
if hash_idx > 0
if (fragment_len = refid.length - 1 - hash_idx) > 0
path, fragment = (refid.slice 0, hash_idx), (refid.slice hash_idx + 1, fragment_len)
else
path = refid.chop
end
if macro
if path.end_with? '.adoc'
src2src = path = path.slice 0, path.length - 5
elsif !(Helpers.extname? path)
src2src = path
end
elsif path.end_with?(*ASCIIDOC_EXTENSIONS.keys)
src2src = path = path.slice 0, (path.rindex '.')
else
src2src = path
end
else
target, fragment = refid, (refid.slice 1, refid.length)
end
elsif macro
if refid.end_with? '.adoc'
src2src = path = refid.slice 0, refid.length - 5
elsif Helpers.extname? refid
path = refid
else
fragment = refid
end
else
fragment = refid
end
# handles: #id
if target
refid = fragment
logger.info %(possible invalid reference: #{refid}) if logger.info? && !doc.catalog[:refs][refid]
elsif path
# handles: path#, path#id, path.adoc#, path.adoc#id, or path.adoc (xref macro only)
# the referenced path is the current document, or its contents have been included in the current document
if src2src && (doc.attributes['docname'] == path || doc.catalog[:includes][path])
if fragment
refid, path, target = fragment, nil, %(##{fragment})
logger.info %(possible invalid reference: #{refid}) if logger.info? && !doc.catalog[:refs][refid]
else
refid, path, target = nil, nil, '#'
end
else
refid, path = path, %(#{doc.attributes['relfileprefix'] || ''}#{path}#{src2src ? (doc.attributes.fetch 'relfilesuffix', doc.outfilesuffix) : ''})
if fragment
refid, target = %(#{refid}##{fragment}), %(#{path}##{fragment})
else
target = path
end
end
# handles: id (in compat mode or when natural xrefs are disabled)
elsif doc.compat_mode || !Compliance.natural_xrefs
refid, target = fragment, %(##{fragment})
logger.info %(possible invalid reference: #{refid}) if logger.info? && !doc.catalog[:refs][refid]
# handles: id
elsif doc.catalog[:refs][fragment]
refid, target = fragment, %(##{fragment})
# handles: Node Title or Reference Text
# do reverse lookup on fragment if not a known ID and resembles reftext (contains a space or uppercase char)
elsif ((fragment.include? ' ') || fragment.downcase != fragment) && (refid = doc.resolve_id fragment)
fragment, target = refid, %(##{refid})
else
refid, target = fragment, %(##{fragment})
logger.info %(possible invalid reference: #{refid}) if logger.info?
end
attrs['path'] = path
attrs['fragment'] = fragment
attrs['refid'] = refid
Inline.new(self, :anchor, link_text, type: :xref, target: target, attributes: attrs).convert
end
end
if found_macroish && (text.include? 'tnote')
text = text.gsub InlineFootnoteMacroRx do
# honor the escape
next $&.slice 1, $&.length if $&.start_with? RS
# footnoteref
if $1
if $3
id, content = $3.split ',', 2
logger.warn %(found deprecated footnoteref macro: #{$&}; use footnote macro with target instead) unless doc.compat_mode
else
next $&
end
# footnote
else
id = $2
content = $3
end
if id
if (footnote = doc.footnotes.find {|candidate| candidate.id == id })
index, content = footnote.index, footnote.text
type, target, id = :xref, id, nil
elsif content
content = restore_passthroughs(normalize_text content, true, true)
index = doc.counter('footnote-number')
doc.register(:footnotes, Document::Footnote.new(index, id, content))
type, target = :ref, nil
else
logger.warn %(invalid footnote reference: #{id})
type, target, content, id = :xref, id, id, nil
end
elsif content
content = restore_passthroughs(normalize_text content, true, true)
index = doc.counter('footnote-number')
doc.register(:footnotes, Document::Footnote.new(index, id, content))
type = target = nil
else
next $&
end
Inline.new(self, :footnote, content, attributes: { 'index' => index }, id: id, target: target, type: type).convert
end
end
text
end
# Public: Substitute post replacements
#
# text - The String text to process
#
# Returns the converted String text
def sub_post_replacements text
#if attr? 'hardbreaks-option', nil, true
if @attributes['hardbreaks-option'] || @document.attributes['hardbreaks-option']
lines = text.split LF, -1
return text if lines.size < 2
last = lines.pop
(lines.map do |line|
Inline.new(self, :break, (line.end_with? HARD_LINE_BREAK) ? (line.slice 0, line.length - 2) : line, type: :line).convert
end << last).join LF
elsif (text.include? PLUS) && (text.include? HARD_LINE_BREAK)
text.gsub(HardLineBreakRx) { Inline.new(self, :break, $1, type: :line).convert }
else
text
end
end
# Public: Apply verbatim substitutions on source (for use when highlighting is disabled).
#
# source - the source code String on which to apply verbatim substitutions
# process_callouts - a Boolean flag indicating whether callout marks should be substituted
#
# Returns the substituted source
def sub_source source, process_callouts
process_callouts ? sub_callouts(sub_specialchars source) : (sub_specialchars source)
end
# Public: Substitute callout source references
#
# text - The String text to process
#
# Returns the converted String text
def sub_callouts text
callout_rx = (attr? 'line-comment') ? CalloutSourceRxMap[attr 'line-comment'] : CalloutSourceRx
autonum = 0
text.gsub callout_rx do
# honor the escape
if $2
# use sub since it might be behind a line comment
$&.sub RS, ''
else
Inline.new(self, :callout, $4 == '.' ? (autonum += 1).to_s : $4, id: @document.callouts.read_next_id, attributes: { 'guard' => $1 || ($3 == '--' ? [''] : nil) }).convert
end
end
end
# Public: Highlight (i.e., colorize) the source code during conversion using a syntax highlighter, if activated by the
# source-highlighter document attribute. Otherwise return the text with verbatim substitutions applied.
#
# If the process_callouts argument is true, this method will extract the callout marks from the source before passing
# it to the syntax highlighter, then subsequently restore those callout marks to the highlighted source so the callout
# marks don't confuse the syntax highlighter.
#
# source - the source code String to syntax highlight
# process_callouts - a Boolean flag indicating whether callout marks should be located and substituted
#
# Returns the highlighted source code, if a syntax highlighter is defined on the document, otherwise the source with
# verbatim substitutions applied
def highlight_source source, process_callouts
# NOTE the call to highlight? is a defensive check since, normally, we wouldn't arrive here unless it returns true
return sub_source source, process_callouts unless (syntax_hl = @document.syntax_highlighter) && syntax_hl.highlight?
source, callout_marks = extract_callouts source if process_callouts
doc_attrs = @document.attributes
syntax_hl_name = syntax_hl.name
if (linenums_mode = (attr? 'linenums') ? (doc_attrs[%(#{syntax_hl_name}-linenums-mode)] || :table).to_sym : nil) &&
(start_line_number = (attr 'start', 1).to_i) < 1
start_line_number = 1
end
highlight_lines = resolve_lines_to_highlight source, (attr 'highlight'), start_line_number if attr? 'highlight'
highlighted, source_offset = syntax_hl.highlight self, source, (attr 'language'),
callouts: callout_marks,
css_mode: (doc_attrs[%(#{syntax_hl_name}-css)] || :class).to_sym,
highlight_lines: highlight_lines,
number_lines: linenums_mode,
start_line_number: start_line_number,
style: doc_attrs[%(#{syntax_hl_name}-style)]
# fix passthrough placeholders that got caught up in syntax highlighting
highlighted = highlighted.gsub HighlightedPassSlotRx, %(#{PASS_START}\\1#{PASS_END}) unless @passthroughs.empty?
# NOTE highlight method may have depleted callouts
callout_marks.nil_or_empty? ? highlighted : (restore_callouts highlighted, callout_marks, source_offset)
end
# Public: Resolve the line numbers in the specified source to highlight from the provided spec.
#
# e.g., highlight="1-5, !2, 10" or highlight=1-5;!2,10
#
# source - The String source.
# spec - The lines specifier (e.g., "1-5, !2, 10" or "1..5;!2;10")
# start - The line number of the first line (optional, default: false)
#
# Returns an [Array] of unique, sorted line numbers.
def resolve_lines_to_highlight source, spec, start = nil
lines = []
spec = spec.delete ' ' if spec.include? ' '
((spec.include? ',') ? (spec.split ',') : (spec.split ';')).map do |entry|
if entry.start_with? '!'
entry = entry.slice 1, entry.length
negate = true
end
if (delim = (entry.include? '..') ? '..' : ((entry.include? '-') ? '-' : nil))
from, _, to = entry.partition delim
to = (source.count LF) + 1 if to.empty? || (to = to.to_i) < 0
if negate
lines -= (from.to_i..to).to_a
else
lines |= (from.to_i..to).to_a
end
elsif negate
lines.delete entry.to_i
elsif !lines.include?(line = entry.to_i)
lines << line
end
end
# If the start attribute is defined, then the lines to highlight specified by the provided spec should be relative to the start value.
unless (shift = start ? start - 1 : 0) == 0
lines = lines.map {|it| it - shift }
end
lines.sort
end
# Public: Extract the passthrough text from the document for reinsertion after processing.
#
# text - The String from which to extract passthrough fragments
#
# Returns the String text with passthrough regions substituted with placeholders
def extract_passthroughs text
compat_mode = @document.compat_mode
passthrus = @passthroughs
text = text.gsub InlinePassMacroRx do
if (boundary = $4) # $$, ++, or +++
# skip ++ in compat mode, handled as normal quoted text
next %(#{$2 ? "#{$1}[#{$2}]#{$3}" : "#{$1}#{$3}"}++#{extract_passthroughs $5}++) if compat_mode && boundary == '++'
if (attrlist = $2)
if (escape_count = $3.length) > 0
# NOTE we don't look for nested unconstrained pass macros
next %(#{$1}[#{attrlist}]#{RS * (escape_count - 1)}#{boundary}#{$5}#{boundary})
elsif $1 == RS
preceding = %([#{attrlist}])
else
if boundary == '++' && (attrlist.end_with? 'x-')
old_behavior = true
attrlist = attrlist.slice 0, attrlist.length - 2
end
attributes = parse_quoted_text_attributes attrlist
end
elsif (escape_count = $3.length) > 0
# NOTE we don't look for nested unconstrained pass macros
next %(#{RS * (escape_count - 1)}#{boundary}#{$5}#{boundary})
end
subs = (boundary == '+++' ? [] : BASIC_SUBS)
if attributes
if old_behavior
passthrus[passthru_key = passthrus.size] = { text: $5, subs: NORMAL_SUBS, type: :monospaced, attributes: attributes }
else
passthrus[passthru_key = passthrus.size] = { text: $5, subs: subs, type: :unquoted, attributes: attributes }
end
else
passthrus[passthru_key = passthrus.size] = { text: $5, subs: subs }
end
else # pass:[]
# NOTE we don't look for nested pass:[] macros
# honor the escape
next $&.slice 1, $&.length if $6 == RS
if (subs = $7)
passthrus[passthru_key = passthrus.size] = { text: (normalize_text $8, nil, true), subs: (resolve_pass_subs subs) }
else
passthrus[passthru_key = passthrus.size] = { text: (normalize_text $8, nil, true) }
end
end
%(#{preceding || ''}#{PASS_START}#{passthru_key}#{PASS_END})
end if (text.include? '++') || (text.include? '$$') || (text.include? 'ss:')
pass_inline_char1, pass_inline_char2, pass_inline_rx = InlinePassRx[compat_mode]
text = text.gsub pass_inline_rx do
preceding = $1
attrlist = $2
escape_mark = RS if (quoted_text = $3).start_with? RS
format_mark = $4
content = $5
if compat_mode
old_behavior = true
elsif (old_behavior = attrlist && (attrlist.end_with? 'x-'))
attrlist = attrlist.slice 0, attrlist.length - 2
end
if attrlist
if format_mark == '`' && !old_behavior
next extract_inner_passthrough content, %(#{preceding}[#{attrlist}]#{escape_mark})
elsif escape_mark
# honor the escape of the formatting mark
next %(#{preceding}[#{attrlist}]#{quoted_text.slice 1, quoted_text.length})
elsif preceding == RS
# honor the escape of the attributes
preceding = %([#{attrlist}])
else
attributes = parse_quoted_text_attributes attrlist
end
elsif format_mark == '`' && !old_behavior
next extract_inner_passthrough content, %(#{preceding}#{escape_mark})
elsif escape_mark
# honor the escape of the formatting mark
next %(#{preceding}#{quoted_text.slice 1, quoted_text.length})
end
if compat_mode
passthrus[passthru_key = passthrus.size] = { text: content, subs: BASIC_SUBS, attributes: attributes, type: :monospaced }
elsif attributes
if old_behavior
subs = (format_mark == '`' ? BASIC_SUBS : NORMAL_SUBS)
passthrus[passthru_key = passthrus.size] = { text: content, subs: subs, attributes: attributes, type: :monospaced }
else
passthrus[passthru_key = passthrus.size] = { text: content, subs: BASIC_SUBS, attributes: attributes, type: :unquoted }
end
else
passthrus[passthru_key = passthrus.size] = { text: content, subs: BASIC_SUBS }
end
%(#{preceding}#{PASS_START}#{passthru_key}#{PASS_END})
end if (text.include? pass_inline_char1) || (pass_inline_char2 && (text.include? pass_inline_char2))
# NOTE we need to do the stem in a subsequent step to allow it to be escaped by the former
text = text.gsub InlineStemMacroRx do
# honor the escape
next $&.slice 1, $&.length if $&.start_with? RS
if (type = $1.to_sym) == :stem
type = STEM_TYPE_ALIASES[@document.attributes['stem']].to_sym
end
subs = $2
content = normalize_text $3, nil, true
# NOTE drop enclosing $ signs around latexmath for backwards compatibility with AsciiDoc.py
content = content.slice 1, content.length - 2 if type == :latexmath && (content.start_with? '$') && (content.end_with? '$')
subs = subs ? (resolve_pass_subs subs) : ((@document.basebackend? 'html') ? BASIC_SUBS : nil)
passthrus[passthru_key = passthrus.size] = { text: content, subs: subs, type: type }
%(#{PASS_START}#{passthru_key}#{PASS_END})
end if (text.include? ':') && ((text.include? 'stem:') || (text.include? 'math:'))
text
end
# Public: Restore the passthrough text by reinserting into the placeholder positions
#
# text - The String text into which to restore the passthrough text
#
# returns The String text with the passthrough text restored
def restore_passthroughs text
passthrus = @passthroughs
text.gsub PassSlotRx do
if (pass = passthrus[$1.to_i])
subbed_text = apply_subs(pass[:text], pass[:subs])
if (type = pass[:type])
if (attributes = pass[:attributes])
id = attributes['id']
end
subbed_text = Inline.new(self, :quoted, subbed_text, type: type, id: id, attributes: attributes).convert
end
subbed_text.include?(PASS_START) ? restore_passthroughs(subbed_text) : subbed_text
else
logger.error %(unresolved passthrough detected: #{text})
'??pass??'
end
end
end
# Public: Resolve the list of comma-delimited subs against the possible options.
#
# subs - The comma-delimited String of substitution names or aliases.
# type - A Symbol representing the context for which the subs are being resolved (default: :block).
# defaults - An Array of substitutions to start with when computing incremental substitutions (default: nil).
# subject - The String to use in log messages to communicate the subject for which subs are being resolved (default: nil)
#
# Returns An Array of Symbols representing the substitution operation or nothing if no subs are found.
def resolve_subs subs, type = :block, defaults = nil, subject = nil
return if subs.nil_or_empty?
# QUESTION should we store candidates as a Set instead of an Array?
candidates = nil
subs = subs.delete ' ' if subs.include? ' '
modifiers_present = SubModifierSniffRx.match? subs
subs.split(',').each do |key|
modifier_operation = nil
if modifiers_present
if (first = key.chr) == '+'
modifier_operation = :append
key = key.slice 1, key.length
elsif first == '-'
modifier_operation = :remove
key = key.slice 1, key.length
elsif key.end_with? '+'
modifier_operation = :prepend
key = key.chop
end
end
key = key.to_sym
# special case to disable callouts for inline subs
if type == :inline && (key == :verbatim || key == :v)
resolved_keys = BASIC_SUBS
elsif SUB_GROUPS.key? key
resolved_keys = SUB_GROUPS[key]
elsif type == :inline && key.length == 1 && (SUB_HINTS.key? key)
resolved_key = SUB_HINTS[key]
if (candidate = SUB_GROUPS[resolved_key])
resolved_keys = candidate
else
resolved_keys = [resolved_key]
end
else
resolved_keys = [key]
end
if modifier_operation
candidates ||= (defaults ? (defaults.drop 0) : [])
case modifier_operation
when :append
candidates += resolved_keys
when :prepend
candidates = resolved_keys + candidates
when :remove
candidates -= resolved_keys
end
else
candidates ||= []
candidates += resolved_keys
end
end
return unless candidates
# weed out invalid options and remove duplicates (order is preserved; first occurrence wins)
resolved = candidates & SUB_OPTIONS[type]
unless (candidates - resolved).empty?
invalid = candidates - resolved
logger.warn %(invalid substitution type#{invalid.size > 1 ? 's' : ''}#{subject ? ' for ' : ''}#{subject}: #{invalid.join ', '})
end
resolved
end
# Public: Call resolve_subs for the :block type.
def resolve_block_subs subs, defaults, subject
resolve_subs subs, :block, defaults, subject
end
# Public: Call resolve_subs for the :inline type with the subject set as passthrough macro.
def resolve_pass_subs subs
resolve_subs subs, :inline, nil, 'passthrough macro'
end
# Public: Expand all groups in the subs list and return. If no subs are resolved, return nil.
#
# subs - The substitutions to expand; can be a Symbol, Symbol Array, or String
# subject - The String to use in log messages to communicate the subject for which subs are being resolved (default: nil)
#
# Returns a Symbol Array of substitutions to pass to apply_subs or nil if no substitutions were resolved.
def expand_subs subs, subject = nil
case subs
when ::Symbol
subs == :none ? nil : SUB_GROUPS[subs] || [subs]
when ::Array
expanded_subs = []
subs.each do |key|
unless key == :none
if (sub_group = SUB_GROUPS[key])
expanded_subs += sub_group
else
expanded_subs << key
end
end
end
expanded_subs.empty? ? nil : expanded_subs
else
resolve_subs subs, :inline, nil, subject
end
end
# Internal: Commit the requested substitutions to this block.
#
# Looks for an attribute named "subs". If present, resolves substitutions
# from the value of that attribute and assigns them to the subs property on
# this block. Otherwise, uses the substitutions assigned to the default_subs
# property, if specified, or selects a default set of substitutions based on
# the content model of the block.
#
# Returns nothing
def commit_subs
unless (default_subs = @default_subs)
case @content_model
when :simple
default_subs = NORMAL_SUBS
when :verbatim
# NOTE :literal with listparagraph-option gets folded into text of list item later
default_subs = @context == :verse ? NORMAL_SUBS : VERBATIM_SUBS
when :raw
# TODO make pass subs a compliance setting; AsciiDoc.py performs :attributes and :macros on a pass block
default_subs = @context == :stem ? BASIC_SUBS : NO_SUBS
else
return @subs
end
end
if (custom_subs = @attributes['subs'])
@subs = (resolve_block_subs custom_subs, default_subs, @context) || []
else
@subs = default_subs.drop 0
end
# QUESION delegate this logic to a method?
if @context == :listing && @style == 'source' && (syntax_hl = @document.syntax_highlighter) &&
syntax_hl.highlight? && (idx = @subs.index :specialcharacters)
@subs[idx] = :highlight
end
nil
end
# Internal: Parse attributes in name or name=value format from a comma-separated String
#
# attrlist - A comma-separated String list of attributes in name or name=value format.
# posattrs - An Array of positional attribute names (default: []).
# opts - A Hash of options to control how the string is parsed (default: {}):
# :into - The Hash to parse the attributes into (optional, default: false).
# :sub_input - A Boolean that indicates whether to substitute attributes prior to
# parsing (optional, default: false).
# :sub_result - A Boolean that indicates whether to apply substitutions
# single-quoted attribute values (optional, default: true).
# :unescape_input - A Boolean that indicates whether to unescape square brackets prior
# to parsing (optional, default: false).
#
# Returns an empty Hash if attrlist is nil or empty, otherwise a Hash of parsed attributes.
def parse_attributes attrlist, posattrs = [], opts = {}
return {} if attrlist ? attrlist.empty? : true
attrlist = normalize_text attrlist, true, true if opts[:unescape_input]
attrlist = @document.sub_attributes attrlist if opts[:sub_input] && (attrlist.include? ATTR_REF_HEAD)
# substitutions are only performed on attribute values if block is not nil
block = self if opts[:sub_result]
if (into = opts[:into])
AttributeList.new(attrlist, block).parse_into(into, posattrs)
else
AttributeList.new(attrlist, block).parse(posattrs)
end
end
private
# This method is used in cases when the attrlist can be mixed with the text of a macro.
# If no attributes are detected aside from the first positional attribute, and the first positional
# attribute matches the attrlist, then the original text is returned.
def extract_attributes_from_text text, default_text = nil
attrlist = (text.include? LF) ? (text.tr LF, ' ') : text
if (resolved_text = (attrs = (AttributeList.new attrlist, self).parse)[1])
# NOTE if resolved text remains unchanged, clear attributes and return unparsed text
resolved_text == attrlist ? [text, attrs.clear] : [resolved_text, attrs]
else
[default_text, attrs]
end
end
# Internal: Extract the callout numbers from the source to prepare it for syntax highlighting.
def extract_callouts source
callout_marks = {}
autonum = lineno = 0
last_lineno = nil
callout_rx = (attr? 'line-comment') ? CalloutExtractRxMap[attr 'line-comment'] : CalloutExtractRx
# extract callout marks, indexed by line number
source = (source.split LF, -1).map do |line|
lineno += 1
line.gsub callout_rx do
# honor the escape
if $2
# use sub since it might be behind a line comment
$&.sub RS, ''
else
(callout_marks[lineno] ||= []) << [$1 || ($3 == '--' ? [''] : nil), $4 == '.' ? (autonum += 1).to_s : $4]
last_lineno = lineno
''
end
end
end.join LF
if last_lineno
source = %(#{source}#{LF}) if last_lineno == lineno
else
callout_marks = nil
end
[source, callout_marks]
end
# Internal: Restore the callout numbers to the highlighted source.
def restore_callouts source, callout_marks, source_offset = nil
if source_offset
preamble = source.slice 0, source_offset
source = source.slice source_offset, source.length
else
preamble = ''
end
lineno = 0
preamble + ((source.split LF, -1).map do |line|
if (conums = callout_marks.delete lineno += 1)
if conums.size == 1
guard, numeral = conums[0]
%(#{line}#{Inline.new(self, :callout, numeral, id: @document.callouts.read_next_id, attributes: { 'guard' => guard }).convert})
else
%(#{line}#{conums.map do |guard_it, numeral_it|
Inline.new(self, :callout, numeral_it, id: @document.callouts.read_next_id, attributes: { 'guard' => guard_it }).convert
end.join ' '})
end
else
line
end
end.join LF)
end
# Internal: Extract nested single-plus passthrough; otherwise return unprocessed
def extract_inner_passthrough text, pre
if (text.end_with? '+') && (text.start_with? '+', '\+') && SinglePlusInlinePassRx =~ text
if $1
%(#{pre}`+#{$2}+`)
else
@passthroughs[passthru_key = @passthroughs.size] = { text: $2, subs: BASIC_SUBS }
%(#{pre}`#{PASS_START}#{passthru_key}#{PASS_END}`)
end
else
%(#{pre}`#{text}`)
end
end
# Internal: Convert a quoted text region
#
# match - The MatchData for the quoted text region
# type - The quoting type (single, double, strong, emphasis, monospaced, etc)
# scope - The scope of the quoting (constrained or unconstrained)
#
# Returns The converted String text for the quoted text region
def convert_quoted_text match, type, scope
if match[0].start_with? RS
if scope == :constrained && (attrs = match[2])
unescaped_attrs = %([#{attrs}])
else
return match[0].slice 1, match[0].length
end
end
if scope == :constrained
if unescaped_attrs
%(#{unescaped_attrs}#{Inline.new(self, :quoted, match[3], type: type).convert})
else
if (attrlist = match[2])
id = (attributes = parse_quoted_text_attributes attrlist)['id']
type = :unquoted if type == :mark
end
%(#{match[1]}#{Inline.new(self, :quoted, match[3], type: type, id: id, attributes: attributes).convert})
end
else
if (attrlist = match[1])
id = (attributes = parse_quoted_text_attributes attrlist)['id']
type = :unquoted if type == :mark
end
Inline.new(self, :quoted, match[2], type: type, id: id, attributes: attributes).convert
end
end
# Internal: Substitute replacement text for matched location
#
# returns The String text with the replacement characters substituted
def do_replacement m, replacement, restore
if (captured = m[0]).include? RS
# we have to use sub since we aren't sure it's the first char
captured.sub RS, ''
else
case restore
when :none
replacement
when :bounding
m[1] + replacement + m[2]
else # :leading
m[1] + replacement
end
end
end
# Internal: Inserts text into a formatted text enclosure; used by xreftext
alias sub_placeholder sprintf unless RUBY_ENGINE == 'opal'
# Internal: Parse the attributes that are defined on quoted (aka formatted) text
#
# str - A non-nil String of unprocessed attributes;
# space-separated roles (e.g., role1 role2) or the id/role shorthand syntax (e.g., #idname.role)
#
# Returns a Hash of attributes (role and id only)
def parse_quoted_text_attributes str
# NOTE attributes are typically resolved after quoted text, so substitute eagerly
str = sub_attributes str if str.include? ATTR_REF_HEAD
# for compliance, only consider first positional attribute (very unlikely)
str = str.slice 0, (str.index ',') if str.include? ','
if (str = str.strip).empty?
{}
elsif (str.start_with? '.', '#') && Compliance.shorthand_property_syntax
before, _, after = str.partition '#'
attrs = {}
if after.empty?
attrs['role'] = (before.tr '.', ' ').lstrip if before.length > 1
else
id, _, roles = after.partition '.'
attrs['id'] = id unless id.empty?
if roles.empty?
attrs['role'] = (before.tr '.', ' ').lstrip if before.length > 1
elsif before.length > 1
attrs['role'] = ((before + '.' + roles).tr '.', ' ').lstrip
else
attrs['role'] = roles.tr '.', ' '
end
end
attrs
else
{ 'role' => str }
end
end
# Internal: Normalize text to prepare it for parsing.
#
# If normalize_whitespace is true, strip surrounding whitespace and fold newlines. If unescape_closing_square_bracket
# is set, unescape any escaped closing square brackets.
#
# Returns the normalized text String
def normalize_text text, normalize_whitespace = nil, unescape_closing_square_brackets = nil
unless text.empty?
text = text.strip.tr LF, ' ' if normalize_whitespace
text = text.gsub ESC_R_SB, R_SB if unescape_closing_square_brackets && (text.include? R_SB)
end
text
end
# Internal: Split text formatted as CSV with support
# for double-quoted values (in which commas are ignored)
def split_simple_csv str
if str.empty?
[]
elsif str.include? '"'
values = []
accum = ''
quote_open = nil
str.each_char do |c|
case c
when ','
if quote_open
accum += c
else
values << accum.strip
accum = ''
end
when '"'
quote_open = !quote_open
else
accum += c
end
end
values << accum.strip
else
str.split(',').map {|it| it.strip }
end
end
end
end