UNPKG

asciidoctor-external-callout

Version:

Asciidoctor extension that adds support for callouts added outside the listing block.

291 lines (202 loc) 9.63 kB
require('@asciidoctor/core') module.exports = function (registry) { registry.treeProcessor(function () { const self = this const CALLOUT_SOURCE_BLOCK_ROLE = 'external-callout-block' const CALLOUT_ORDERED_LIST_ROLE = 'external-callout-list' const STANDALONE_CALLOUT_LIST_STYLE = 'calloutlist' const LOCATION_TOKEN_RX = /@(\d+)|(@\/(?:\\\/|[^\/])+?\/[ig]{0,2})/ const LOCATION_TOKEN_ARRAY_RX = /^(@\d+|@\/(?:\\\/|[^\/]|)+?\/[ig]{0,2})((\s+@\d+)|(\s+@\/(?:\\\/|[^\/]|)+?\/[ig]{0,2}))*$/ const SEARCH_STRING_RX = /\/((?:\\\/|[^\/])+?)\// const SEARCH_OPTIONS_RX = /\/(?:\\\/|[^\/])+?\/([ig]{0,2})/ self.process(document => { let lists = document.findBy({'context': 'olist'}) lists.forEach(list => { if (list.getStyle().includes(STANDALONE_CALLOUT_LIST_STYLE)) { list.context = list.node_name = 'colist' } else { if (is_external_callout_list(list)) { try { let owner_block = owning_block(document, list) if (!owner_block.getSubstitutions().includes("callouts")) { owner_block.getSubstitutions().push("callouts") } process_callouts(list, owner_block) list.context = list.node_name = 'colist' if (!list.getRoles().includes(CALLOUT_ORDERED_LIST_ROLE)) { list.addRole(CALLOUT_ORDERED_LIST_ROLE) } if (!owner_block.getRoles().includes(CALLOUT_SOURCE_BLOCK_ROLE)) { owner_block.addRole(CALLOUT_SOURCE_BLOCK_ROLE) } } catch (e) { console.error(e) } } } }) return document }) /** * Make sure that this list is in the correct format * We don't process it otherwise. * @param list */ function is_external_callout_list(list) { return list.getBlocks().every((x) => { let item_under_test = x.text let location_token_index = item_under_test.search(LOCATION_TOKEN_RX) // if we don't find the start of the location tokens, or the token is the first item, // then we don't need to carry on any further; this is not the list we are looking for. if (location_token_index < 1) { return false } let location_tokens = item_under_test.substring(location_token_index).trim() return location_tokens.match(LOCATION_TOKEN_ARRAY_RX) && x.getBlocks().length === 0 }) } function process_callouts(list, owner_block) { list.getBlocks().forEach((list_item) => { // Don't use getText() because this returns the string // preformatted – just get the raw item instead. let item = list_item.text let location_token_index = item.search(LOCATION_TOKEN_RX) // Just find the locations tokens at the end of the item. let location_tokens = item.substring(location_token_index).trim() // The text of the item itself let phrase = item.substring(0, location_token_index - 1).trim() let locations = location_tokens.scan(new RegExp(LOCATION_TOKEN_RX, 'g')) .flat().filter(token => token !== undefined) let line_numbers = new Set() locations.forEach(location => { if (location.is_numeric()) { let line_number = parseInt(location) if (line_number > 0 && line_number <= owner_block.getSourceLines().length) { line_numbers.add(line_number - 1) } else { console.log(`Line number out of range ==> ${line_number}`) } } else { //We must be dealing with a string matcher // Is this a global search? let search_options = get_search_options(location) let search_string = location.match(SEARCH_STRING_RX)[1] let numbers = find_matching_lines(search_string, search_options["global_search"], search_options["case_insensitive"], owner_block) if (numbers.size > 0) { // This is how you perform a union of two sets, apparently. line_numbers = new Set([...line_numbers, ...numbers]) } else { console.log(`Phrase not found ==> ${location}`) } } }) //Now add each callout to the listing line_numbers.forEach(line_number => { let callout = find_list_index_for_item(list_item) owner_block.getSourceLines()[line_number] += ` <${callout}>` }) list_item.setText(phrase) }) } function owning_block(document, list) { let all_blocks = document.findBy({}) if (all_blocks === undefined) { throw "There are no valid blocks in this document" } let index_back = all_blocks.findIndex((block) => block === list) if (index_back === undefined) { // Shouldn't happen because we managed to get this far throw "Error – could not locate our ordered list" } // From the position of our ordered list, scan backwards until we find the next correct // source listing, looking for hazards along the way. while (index_back > 0) { index_back = index_back - 1; if (all_blocks[index_back].getContext() === 'listing') { // We have found our matching block return all_blocks[index_back] } if (all_blocks[index_back].getContext() === 'colist') { // We have hit another callout list, but there was no list block first. // Assume we have an error throw "Callout list found while seeking listing" } } // If we didn't find a listing, then this document has probably got // bits missing. throw "No listing found" } function find_list_index_for_item(list_item) { let list = list_item.getParent() let index_of_this_item = list.getBlocks().findIndex((item) => item === list_item) return index_of_this_item + 1 } function find_matching_lines(phrase, global_search, case_insensitive, owner_block) { let found_line_numbers = new Set() let search_term if (case_insensitive) { search_term = new RegExp(phrase, 'i') } else { search_term = new RegExp(phrase) } const sourceLines = owner_block.getSourceLines(); for (let i = 0; i < sourceLines.length; i++) { const line = sourceLines[i]; if (line.match(search_term) != null) { found_line_numbers.add(i); if (!global_search) { break; } } } return found_line_numbers } function get_search_options(location) { let options = { "case_insensitive": false, "global_search": false } let matches = location.match(SEARCH_OPTIONS_RX) if (matches === "") { return options } // This is just for completeness but make sure that // none of the options is mentioned twice in the list if (/^(.).*\1$/.test(matches[1])) { throw `Invalid search options: ${matches[1]}` } if (matches[1].includes('g')) { options["global_search"] = true; } if (matches[1].includes('i')) { options["case_insensitive"] = true } return options; } String.prototype.is_numeric = function () { return this.match(/^\d+$/) } //Borrowed this function so that we can have // a scan that works in the same way as the Ruby version of the // callout extension. String.prototype.scan = function (regexp) { if (!regexp.global) { throw new Error("RegExp without global (g) flag is not supported.") } let result = [] let m while (m = regexp.exec(this)) { if (m.length >= 2) { result.push(m.slice(1)); } else { result.push(m[0]) } } return result } }) }