Jump to content

Module:Docbunto

From The Petit Planet Wiki
Revision as of 15:06, 2 September 2026 by ReisuDesign (talk | contribs) (Created page with "--- Docbunto, based on the version on the dev wiki. -- -- Compared to the version on the dev wiki, this version: -- * changes documentation output format to always produce valid HTML and allow CSS styling -- * adds support for object types that link to other modules -- * adds better support for block documentation comments -- * fixes a few bugs -- -- (Note: @export seems buggy in the version on the dev wiki, and may not be --...")
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)
Module documentation

Lua error in package.lua at line 80: module 'Dev:I18n' not found.

--- Docbunto, based on [[w:c:dev:Global_Lua_Modules/Docbunto|the version on the dev wiki]].
--
--  Compared to the version on the dev wiki, this version:
--  * changes documentation output format to always produce valid HTML and allow CSS styling
--  * adds support for object types that link to other modules
--  * adds better support for block documentation comments
--  * fixes a few bugs
--
--  (Note: @export seems buggy in the version on the dev wiki, and may not be
--  completely fixed in this version either.)
--  
--  Documentation from the dev wiki:
--
--  Docbunto is an automatic documentation generator for Scribunto modules.
--  The module is based on LuaDoc and LDoc. It produces documentation in
--  the form of MediaWiki markup, using `@tag`-prefixed comments embedded
--  in the source code of a Scribunto module. The taglet parser & doclet
--  renderer Docbunto uses are also publicly exposed to other modules.
--  
--  Docbunto code items are introduced by a block comment (`--[[]]--`), an
--  inline comment with three hyphens (`---`), or an inline `@tag` comment.
--  The module can use static code analysis to infer variable names, item
--  privacy (`local` keyword), tables (`{}` constructor) and functions
--  (`function` keyword). MediaWiki and Markdown formatting is supported.
--  
--  Items are usually rendered in the order they are defined, if they are
--  public items, or emulated classes extending the Lua primitives. There
--  are many customisation options available to change Docbunto behaviour.
--  
--  @module             docbunto
--  @alias              p
--  @require            Module:I18n
--  @require            Module:Lexer
--  @require            Module:T
--  @require            Module:Unindent
--  @require            Module:Yesno
--  @image              Docbunto.svg
--  @author             [[User:8nml|8nml]]
--  @attribution        [[github:stevedonovan|@stevedonovan]] ([[github:stevedonovan/LDoc|Github]])
--  @release            stable
--  <nowiki>
local p = {}

local lib = require('Module:Feature')

-- CSS classes
local VISIBILITYLEVEL_CLASS = 'docbunto-visibilitylevel'
local ITEM_CLASS = 'docbunto-item'
local SECTION_CLASS = 'docbunto-section'
local ITEM_HEADER_CLASS = 'docbunto-item-header'
local ITEM_BODY_CLASS = 'docbunto-item-body'
local TAGGROUP_CLASS = 'docbunto-taggroup'
local TAGGROUP_HEADER_CLASS = 'docbunto-taggroup-header'
local TAGGROUP_TAG_CLASS = 'docbunto-taggroup-tag'
local TAG_KEYS_CLASS = 'docbunto-tag-keys'


local INTERWIKI = 'w:c:dev:'

local NEEDS_NEWLINE = '^[{|!};:#*=]' -- tables, dl, ul, ol, headings
local function append_description(description, new_line)
	new_line = lib.trim(new_line)
	local separator
	if #description == 0 then
		separator = ''
	elseif #new_line == 0 or description:find('\n$') or new_line:find(NEEDS_NEWLINE) then
		separator = '\n'
	else
		separator = ' '
	end
	return description .. separator .. new_line
end


-- code from the dev wiki:
-- (revision: https://dev.fandom.com/wiki/Module:Docbunto?oldid=193112)

--  Module dependencies.
local title = mw.title.getCurrentTitle()
local i18n = require('Dev:I18n').loadMessages('Module:Docbunto/i18n')
local references = mw.loadData('Dev:Docbunto/references')
local lexer = require('Dev:Lexer')
local unindent = require('Dev:Unindent')
local yesno = require('Dev:Yesno')

--  Module variables.
local DEFAULT_TITLE = title.text:gsub('/doc$', '')
local frame, gsub, match

--  Docbunto variables & tag tokens.
local TAG_MULTI = 'M'
local TAG_ID = 'ID'
local TAG_SINGLE = 'S'
local TAG_TYPE = 'T'
local TAG_FLAG = 'N'
local TAG_MULTI_LINE = 'ML'
local TAG_NAMED_MULTI = 'NM'

--  Docbunto processing patterns.
local DOCBUNTO_SUMMARY, DOCBUNTO_TYPE
local DOCBUNTO_TAG, DOCBUNTO_TAG_VALUE, DOCBUNTO_TAG_MOD_VALUE

--  Docbunto private logic.

--- @{string.find} optimisation for @{string} functions.
--  Resets patterns for each documentation build.
--  @function           strfind_wrap
--  @param              {function} strfunc String library function.
--  @return             {function} Function wrapped in @{string.find} check.
--  @local
function strfind_wrap(func)
	return function(...)
		local arg = {...}
		if string.find(arg[1], arg[2]) then
			return func(...);
		end
	end
end

local function isNotEmpty(tbl)
	return tbl[1] ~= nil
end

local function count(str, item)
	local output = 0
	local _, pos = str:find(item, pos, true)
	while pos do
		output = output + 1
		_, pos = str:find(item, pos + 1, true)
	end
	return output
end

--- Pattern configuration function.
--  Resets patterns for each documentation build.
--  @function           configure_patterns
--  @param              {table} options Configuration options.
--  @param              {boolean} options.colon Colon mode.
--  @local
local function configure_patterns(options)
	-- Setup Unicode or ASCII character encoding (optimisation).
	gsub = strfind_wrap(
		options.unicode
			and mw.ustring.gsub
			or  string.gsub
	)
	match = strfind_wrap(
		options.unicode
			and mw.ustring.match
			or  string.match
	)
	DOCBUNTO_SUMMARY =
		options.iso639_th
			and '^[^ ]+'
			or
		options.unicode
			and '^[^.։。।෴۔።]+[.։。।෴۔።]?'
			or  '^[^.]+%.?'

	-- Set up parsing tag patterns with colon mode support.
	DOCBUNTO_TAG = options.colon and '^%s*(%w+):' or '^%s*@(%w+)'
	DOCBUNTO_TAG_VALUE = DOCBUNTO_TAG .. '(.*)'
	DOCBUNTO_TAG_MOD_VALUE = DOCBUNTO_TAG .. '(%b[])(.*)'
	DOCBUNTO_TYPE = '^{({*[^}]+}*)}%s*'
end


--- Markdown header converter.
--  @function           markdown_header
--  @param              {string} hash Leading hash.
--  @param              {string} text Header text.
--  @return             {string} MediaWiki header.
--  @local
local function markdown_header(hash, text)
	local symbol = '='
	return
		'\n' .. symbol:rep(#hash) ..
		' ' .. text ..
		' ' .. symbol:rep(#hash) ..
		'\n'
end

--- Item reference formatting.
--  @function           item_reference
--  @param              {string} ref Item reference.
--  @return             {string} Internal MediaWiki link to article item.
--  @local
local function item_reference(ref)
	local temp = lib.split(ref, '|')
	local item = temp[1]
	local text = temp[2] or temp[1]

	if references.items[item] then
		item = INTERWIKI .. references.items[item]
	elseif item:find('#', 1, true) then
		item = mw.site.namespaces[828].name .. ':' .. item
	else
		item = '#' .. item
	end

	return '<code>' .. '[[' .. item .. '|' .. text .. ']]' .. '</code>'
end

--- Markdown preprocessor to MediaWiki format.
--  @function           markdown
--  @param[opt]         {string} str Unprocessed Markdown string.
--  @return[opt]        {string} MediaWiki-compatible markup with HTML formatting (or nil if `str` is nil).
--  @local
local function markdown(str)
	if not str then return nil end
	if type(str) ~= "string" then
		str = tostring(str)
	end
	-- Bold & italic tags.
	str = str:gsub('%*%*%*([^\n*]+)%*%*%*', '<b><i>%1<i></b>')
	str = str:gsub('%*%*([^\n*]+)%*%*', '<b>%1</b>')
	str = str:gsub('%*([^\n*]+)%*', '<i>%1</i>')

	-- Self-closing header support.
	str = str:gsub('%f[^\n%z](#+) *([^\n#]+) *#+%s', markdown_header)

	-- External and internal links.
	str = str:gsub('%[([^\n%]]+)%]%(([^\n][^\n)]-)%)', '[%2 %1]')
	str = str:gsub('%@{([^\n}]+)}', item_reference)

	-- Programming & scientific notation.
	--'<code><nowiki>%1</nowiki></code>'
	str = str:gsub('%f["`]`([^\n`]+)`%f[^"`]', function(m) return '<code>' .. mw.text.nowiki(m) .. '</code>' end)
	-- '<chem>%1</chem>'
	str = str:gsub('%$%$\\ce{([^\n}]+)}%$%$', function(m) return frame:extensionTag('chem', m) end)
	-- '<math display="inline">%1</math>'
	str = str:gsub('%$%$([^\n$]+)%$%$', function(m) return frame:extensionTag('math', m, {display='inline'}) end)

	-- Strikethroughs and superscripts.
	-- (These must be processed after math/chem/code syntax.)
	str = str:gsub('~~([^\n~]+)~~', '<del>%1</del>')
	str = str:gsub('%^%(([^)]+)%)', '<sup>%1</sup>')
	str = str:gsub('%^%s*([^%s%p]+)', '<sup>%1</sup>')

	return str
end

local maybe_md
--- Enables/disables markdown processing for each documentation build.
--  @param              {table} options Configuration options.
--  @local
function configure_markdown(options)
	maybe_md = options.plain and (function(str) return str and tostring(str) or nil end) or markdown
end

--- Tag processor function.
--  @function           process_tag
--  @param              {string} str Tag string to process.
--  @param[opt]         {number} line_no The source line number that defines the tag.
--  @return             {table} Tag object.
--  @local
local function process_tag(str, line_no)
	local tag = {
		lineno=line_no,
		height=count(str, '\n') + 1
	}

	if str:find(DOCBUNTO_TAG_MOD_VALUE) then
		tag.category, tag.modifiers, tag.value = str:match(DOCBUNTO_TAG_MOD_VALUE)
		local modifiers = {}

		-- remove [] from tag.modifiers before splitting
		for mod in lib.gsplit(tag.modifiers:sub(2, -2), ',') do
			local key, val = mod:match('^(.-)%s*=%s*(.*)$')
			if key then
				modifiers[key] = val
			else
				modifiers[mod] = true
			end
		end

		if modifiers.optchain then
			modifiers.opt = modifiers.opt or true
			modifiers.optchain = nil
		end

		tag.modifiers = modifiers

	else
		tag.category, tag.value = str:match(DOCBUNTO_TAG_VALUE)
		tag.modifiers = {}
	end

	tag.value = lib.trim(tag.value)

	if p.tags._type_alias[tag.category] then
		if p.tags._type_alias[tag.category] ~= 'variable' then
			tag.value = p.tags._type_alias[tag.category] .. ' ' .. tag.value
			tag.category = 'field'
		end

		if tag.value:match('^%S+') ~= '...' then
		   tag.value = tag.value:gsub('^(%S+)', '{%1}')
		end
	end

	tag.category = p.tags._alias[tag.category] or tag.category

	if tag.category ~= 'usage' and tag.value:find(DOCBUNTO_TYPE) then
		tag.type = tag.value:match(DOCBUNTO_TYPE)
		if tag.type:find('^%?') then
			tag.type = tag.type:sub(2) .. '|nil'
		end
		tag.value = tag.value:gsub(DOCBUNTO_TYPE, '')
	end

	if p.tags[tag.category] == TAG_FLAG then
		tag.value = true
	end

	return tag
end

--- Module info extraction utility.
--  @function           extract_info
--  @param              {table} documentation Package doclet info.
--  @return             {table} Information name-value map.
--  @local
local function extract_info(documentation)
	local info = {}

	for _, tag in ipairs(documentation.tags) do
		if p.tags._module_info[tag.category] then
			if info[tag.category] then
				if not info[tag.category]:find('^%* ') then
					info[tag.category] = '* ' .. info[tag.category]
				end
				info[tag.category] = info[tag.category] .. '\n* ' .. tag.value

			else
				info[tag.category] = tag.value
			end
		end
	end

	return info
end

--- Type extraction utility.
--  @function           extract_type
--  @param              {table} item Item documentation data.
--  @return             {string} Item type.
--  @local
local function extract_type(item)
	local item_cat, item_type
	for _, tag in ipairs(item.tags) do
		if p.tags[tag.category] == TAG_TYPE then
			item_cat = tag.category

			
			item_type = tag.type
			break
		end
	end
	return item_cat, item_type
end

--- Name extraction utility.
--  @function           extract_name
--  @param              {table} item Item documentation data.
--  @param[opt]         {string} project_code String containing full project source code.
--                         If specified, assumes that `item` is the project and checks
--                         its return value when looking for a name.
--  @return             {string} Item name.
--  @local
local function extract_name(item, project_code)
	opts = opts or {}
	local item_name
	for _, tag in ipairs(item.tags) do
		if p.tags[tag.category] == TAG_TYPE then
			item_name = tag.value; break;
		end
	end

	if item_name or not project_code then
		return item_name
	end

	item_name = project_code:match('\nreturn%s+([%w_]+)')

	-- TODO why alias as p?
	if item_name == 'p' and not item.tags['alias'] then
		local implied_alias = { name = 'alias', value = 'p' }
		item.tags['alias'] = {implied_alias}
		table.insert(item.tags, implied_alias)
	end

	item_name = (item_name and item_name ~= 'p')
		and item_name
		or  item.filename
				:gsub('^' .. mw.site.namespaces[828].name .. ':', '')
				:gsub('^(%u)', mw.ustring.lower)
				:gsub('/', '.'):gsub(' ', '_')

	return item_name
end

--- Source code utility for item name detection.
--  @function           deduce_name
--  @param              {string} tokens Stream tokens for first line.
--  @param              {string} index Stream token index.
--  @param              {table} opts Configuration options.
--  @param[opt]         {boolean} opts.lookahead Whether a variable name succeeds the index.
--  @param[opt]         {boolean} opts.lookbehind Whether a variable name precedes the index.
--  @return             {string} Item name.
--  @local
local function deduce_name(tokens, index, opts) -- TODO this should be two different functions
	local name = ''

	if opts.lookbehind then
		for i2 = index, 1, -1 do
			if tokens[i2].type ~= 'keyword' then
				name = tokens[i2].data .. name
			else
				break
			end
		end

	elseif opts.lookahead then
		for i2 = index, #tokens do
			if tokens[i2].type ~= 'keyword' and not tokens[i2].data:find('^%(') then
				name = name .. tokens[i2].data
			else
				break
			end
		end
	end

	return name
end

--- Code analysis utility.
--  @function           code_static_analysis
--  @param              {table} item Item documentation data.
--  @local
local function code_static_analysis(item)
	if not item.head then return end
	local tokens = item.head
	item.head = nil -- remove token data so that logging documentation to console is shorter
	local t, i = tokens[1], 1
	local item_name, item_cat
	local is_local = false

	while t do -- TODO this seems unnecessary? just handle it in deduce_name
		if t.type == 'whitespace' then
			table.remove(tokens, i)
			-- skips an index, which is fine since there shouldn't be consecutive whitespace tokens
		end

		t, i = tokens[i + 1], i + 1
	end
	t, i = tokens[1], 1

	while t do
		if t.data == '=' then
			item_name = deduce_name(tokens, i - 1, { lookbehind = true })
		end

		if t.data == 'function' then
			item_cat = 'function'
			if not tokens[i + 1].data:find('^%(') then
				item_name = deduce_name(tokens, i + 1, { lookahead = true })
			end
			break
		end

		if t.data:find('^{') then
			item_cat = 'table'
			break
		end

		if t.data == 'local'  then
			is_local = true
		end

		t, i = tokens[i + 1], i + 1
	end

	item.name = item.name or item_name or ''
	item.category = item.category or item_cat
	if item_name or item_cat then
		item.has_source = true
	end
	
	item.implied_local = is_local
end

local function postprocess_tag(tag)
	if p.tags[tag.category] == TAG_NAMED_MULTI then
		tag.name = tag.value:match('^%S+')
		if tag.name then
			tag.value = tag.value:sub(#tag.name + 1)
		else
			tag.name = '?'
		end
	end
	
	if type(tag.modifiers.name) == 'string' then
		tag.name = tag.modifiers.name
	end
	
	-- trim tag value (except flag tags)
	if type(tag.value) == "string" then
		tag.value = lib.trim(tag.value)
	end
	return tag
end

--- Partitions tags into sub-lists based on category and runs post-processing
--  on tag values.
--  @function           postprocess_tags
--  @param              {table} item Item documentation data array.
--  @local
local function postprocess_tags(tags)
	for _, element in ipairs(tags) do
		if not tags[element.category] then
			tags[element.category] = {}
		end
		table.insert(tags[element.category], postprocess_tag(element))
	end
end

--- Item export utility.
--  @function           export_item
--  @param              {table} documentation Package documentation data.
--  @param              {string} item_reference Identifier name for item.
--  @param              {string} item_index Identifier name for item.
--  @param              {string} item_alias Export alias for item.
--  @param              {boolean} factory_item Whether the documentation item is a factory function.
--  @local
local function export_item(documentation, item_reference, item_index, item_alias, factory_item)
	-- TODO what if exported multiple times?
	documentation.explicitly_exported[item_reference] = {
		parent_index = factory_item and item_index or 0,
		alias = item_alias
	}
end

--- Subitem tag correction utility.
--  @function           correct_subitem_tag
--  @param              {table} item Item documentation data.
--  @local
local function correct_subitem_tag(item)
	local field_tags = item.tags['field']
	if item.category ~= 'function' or not field_tags then
		return
	end

	item.tags['param'] = item.tags['param'] or {}
	local param_tags = item.tags['param']
	for _, tag_el in ipairs(field_tags) do
		tag_el.category = 'param'
		table.insert(param_tags, tag_el)
	end
	
	item.tags['field'] = nil
end

--- Item override tag utility.
--  @function           override_item_tag
--  @param              {table} item Item documentation data.
--  @param              {string} name Tag name.
--  @param[opt]         {string} target Target property name for tag.
--  @local
local function override_item_tag(item, name, target)
	if item.tags[name] then
		item[target or name] = item.tags[name][1].value
	end
end


--- Doclet type reference preprocessor.
--  Formats types with links to the [[w:c:dev:Lua reference manual|Lua reference manual]] or wiki pages.
--  @function           preop_type
--  @param              {table} item Item documentation data.
--  @param              {table} options Configuration options.
--  @local
local function type_reference(item, options)

	if not options.noluaref and item.name == '...' then
		item.name = '[[' .. INTERWIKI .. 'Lua reference manual#varargs|...]]'
	end

	if not item.type then
		return
	end

	item.type = item.type:gsub('&#32;', '\26')
	local spaces = '%s\26'
	local separators = '[;|]' -- TODO should this allow other punctuation? e.g. commas, parens, brackets
	
	local types = mw.text.split(item.type, separators)
	
	for i, type in ipairs(types) do
		local t = lib.trim(type, spaces)

		local data = references.types[t]
		local name = data and data.name or t
		if not name:match('%.') and not name:match('^%u') and data then
			name = i18n:msg('type-' .. name)
		end
		if data then
			if not options.noluaref then
				types[i] = '[[' .. INTERWIKI .. data.link .. '|' .. name .. ']]'
			end
		else
			local link = t
			if t:find('#', 1, true) then
				link = mw.site.namespaces[828].name .. ':' .. link
			else
				link = '#' .. link
			end
			types[i] = '<code>[[' .. link .. '|' .. name .. ']]</code>'
		end
	end

	item.type = mw.text.listToText(types,
		i18n:msg('separator-comma'),
		#types > 2 and i18n:msg('separator-comma-or') or i18n:msg('separator-or'))
	item.type = item.type:gsub('\26', '&#32;')
end

local function isInstanceFunction(item)
	if item.category ~= 'function' then
		return false
	end
	return not item.tags['static']
end

--- Doclet item renderer.
--  @function           render_item
--  @param              {table} stream Wikitext documentation stream.
--  @param              {table} item Item documentation data.
--  @param              {table} options Configuration options.
--  @param[opt]         {function} preop Item data preprocessor.
--  @return             {table} A stream to append children to.
--  @local
local function render_item(stream, item, options, preop)
	local item_id = item.alias or item.name or '?'
	if preop then preop(item, options) end
	local item_name = item.alias or item.name or '?'

	type_reference(item, options)

	local types = {i18n:msg('itemcategory-' .. item.category)}
	for _, name in ipairs(p.tags._subtype_hierarchy) do
		if item.tags[name] then
			table.insert(types, i18n:msg('itemsubcategory-' .. name))
		end
	end
	table.insert(types, item.type)
	local item_cat = i18n:msg('parentheses', table.concat(types, i18n:msg('separator-dot')))

	if options.strip and item.export and item.hierarchy then
		item_name = item_name:gsub('^[%w_]+[.[]?', '')
	end
	
	local source_link = item.has_source
		and i18n:msg('parentheses', '[[' .. options.filepath .. '#L-' .. item.lineno .. '|' .. i18n:msg('link-source') .. ']]')
		or ''
	
	local dl = stream:tag('dl'):addClass(ITEM_CLASS)
	dl:tag('dt')
		:addClass(ITEM_HEADER_CLASS)
		:wikitext('<code id="', item_id, '">', item_name, '</code>', item_cat, item_typ, source_link)
	
	local dd = dl:tag('dd'):addClass(ITEM_BODY_CLASS):tag('div'):newline()

	if #item.description ~= 0 then
		dd:wikitext(maybe_md(item.description)):newline()
	end
	
	stream:newline()
	return dd
end

--- Doclet tag renderer.
--  @function           render_tag
--  @param              {table} stream Wikitext documentation stream.
--  @param              {string} name Item tag name.
--  @param              {table} tag Item tag data.
--  @param              {table} options Configuration options.
--  @param[opt]         {function} preop Item data preprocessor.
--  @local
local function render_tag(stream, name, tag, options, preop)
	if preop then preop(tag, options) end
	local tag_name = i18n:msg('tag-' .. name, tostring(#tag))
	
	local dl = stream:tag('dl'):addClass(TAGGROUP_CLASS)
	dl:tag('dt')
		:addClass(TAGGROUP_HEADER_CLASS)
		:wikitext('<b>',  tag_name, '</b>', i18n:msg('separator-semicolon')) 
		
	local nesting = {dl}

	for _, tag_el in ipairs(tag) do
		local nesting_level = tag_el.name
			and (
				tag_el.name == '...'
				and 1
				or (select(2, tag_el.name:gsub('[.[]', '')) + 1)
			) or 1
		
		-- handle skipped nesting levels
		while nesting_level > #nesting do
			-- create elements to nest in
			local parent = nesting[#nesting]
			local newParent = parent:tag('dl'):addClass(TAG_KEYS_CLASS)
			newParent:tag('dt'):wikitext(i18n:msg('label-keys'))
			table.insert(nesting, newParent)
		end
		-- handle end of nesting
		local first_in_level = nesting_level > 1 -- never  first in level on level 1, since the tag type dt already exists
		while nesting_level < #nesting do
			table.remove(nesting)
			first_in_level = false
		end
		
		local parent = nesting[#nesting]
		-- create new dl if needed
		if first_in_level then
			parent = parent:tag('dl'):addClass(TAG_KEYS_CLASS)
			parent:tag('dt'):wikitext('Keys:')
			nesting[#nesting] = parent
		end
		
		local dd = parent:tag('dd'):addClass(TAGGROUP_TAG_CLASS):tag('div'):newline()
		table.insert(nesting, dd)
		type_reference(tag_el, options)
		
		local extra_data = {}
		if tag_el.type then
			table.insert(extra_data, tag_el.type)
		end
		if tag_el.modifiers.opt then
			table.insert(extra_data, i18n:msg('optional'))
		end
		if tag_el.source then
			table.insert(extra_data, i18n:msg('line-number', tag_el.source))
		end
		
		if isNotEmpty(extra_data) then
			dd:wikitext(i18n:msg('parentheses', table.concat(extra_data, i18n:msg('separator-colon'))))
		end
		
		dd:newline()
		
		if tag_el.name then
			dd:wikitext('<code>', tag_el.name, '</code>')
			if #tag_el.value ~= 0 then
				dd:wikitext(' ')
			end
		end
		
		dd:wikitext(maybe_md(tag_el.value)):newline()
	end
end

--- Doclet function preprocessor.
--  Formats item name as a function call with top-level arguments.
--  @function           preop_function_name
--  @param              {table} item Item documentation data.
--  @param              {table} options Configuration options.
--  @local
local function preop_function_name(item, options)
	local target = item.alias and 'alias' or 'name'

	item[target] = item[target] .. '('

	if item.tags['param'] then
		for index, tag in ipairs(item.tags['param']) do
			if not tag.name:find('[.[]') then
				item[target] = item[target] .. (index > 1 and ', ' or '')
				
				if tag.modifiers.opt then
					item[target] = item[target] .. '<span style="opacity: 0.65;">['
				end

				item[target] = item[target] ..  tag.name

				if tag.modifiers.opt then
					item[target] = item[target] .. ']</span>'
				end
			end
		end
	end

	item[target] = item[target] .. ')'
end

--- Doclet usage subitem preprocessor.
--  Formats usage example with `<syntaxhighlight>` tag.
--  @function           preop_usage_highlight
--  @param              {table} item Item documentation data.
--  @param              {table} options Configuration options.
--  @local
local function preop_usage_highlight(items, options)
	for _, item in ipairs(items) do
		item.value = unindent(lib.trim(item.value))
		local multi_line = item.value:find('\n')
		local wikitext = item.value:find('^{{.+}}$')
		
		item.value = frame:extensionTag('syntaxhighlight', item.value, {
			lang=(wikitext and 'text' or 'lua'),
			inline=(multi_line and nil or 1),
		})
	end
end

--- Doclet error subitem preprocessor.
--  Formats line numbers (`{#}`) in error tag values.
--  @function           preop_error_line
--  @param              {table} item Item documentation data.
local function preop_error_line(items, options)
	for _, item in ipairs(items) do
		local line = item.modifiers.line or item.lineno + item.height
		if line ~= '' then
			item.source = line
		end
	end
end

--  Docbunto package items.

--- Template entrypoint for [[Template:Docbunto]].
--  @function           p.main
--  @param              {table} f Scribunto frame object.
--  @return             {string} Module documentation output.
function p.main(f)
	local args = require('Module:Arguments').getArgs(f)
	return p.build(args)
end

--- Scribunto documentation generator entrypoint.
--  @function           p.build
--  @param[opt]         {table} options Configuration options.
--  @param[opt]         {string} options.file Module page name (without namespace).
--                      Default: second-level subpage.
--  @param[opt]         {string} options.content An parameter used for debugging
--                      this module. Pass in a string containing source code
--                      to view the documentation it would generate.
--  @param[opt]         {boolean} options.all Include local items in
--                      documentation.
--  @param[opt]         {boolean} options.boilerplate Removal of
--                      boilerplate (license block comments).
--  @param[opt]         {string} options.caption Infobox image caption.
--  @param[opt]         {boolean} options.code Only document Docbunto code
--                      items - exclude article infobox and lede from
--                      rendered documentation. Permits article to be
--                      edited in VisualEditor.
--  @param[opt]         {boolean} options.colon Format tags with a `:` suffix
--                      and without the `@` prefix. This bypasses the "doctag
--                      soup" some authors complain of.
--  @param[opt]         {string} options.image Infobox image.
--  @param[opt]         {boolean} options.noluaref Don't link to the
--                      [[w:c:dev:Lua reference manual|Lua reference manual]]
--                      for types.
--  @param[opt]         {boolean} options.plain Disable Markdown formatting
--                      in documentation.
--  @param[opt]         {string} options.preface Preface text to insert
--                      between lede & item documentation, used to provide
--                      usage and code examples.
--  @param[opt]         {boolean} options.simple Limit documentation to
--                      descriptions only. Removes documentation of
--                      subitem tags such as `@param` and `@field` ([[#Item
--                      subtags|see list]]).
--  @param[opt]         {boolean} options.strip Remove table index in
--                      documentation.
function p.build(args)
	local options = {}
	
	options.file = args.file or args[1] or DEFAULT_TITLE
	options.content = args.content
	options.all = yesno(args.all, false)
	options.boilerplate = yesno(args.boilerplate, false)
	options.caption = args.caption
	options.code = yesno(args.code, false)
	options.colon = yesno(args.colon, false)
	options.image = args.image
	options.noluaref = yesno(args.noluaref, false)
	options.plain = yesno(args.plain, false)
	options.preface = args.preface
	options.simple = yesno(args.simple, false)
	options.strip = yesno(args.strip, false)

	local tagdata = p.taglet(options)
	local docdata = p.doclet(tagdata, options)

	return docdata
end

--- Docbunto taglet parser for Scribunto modules.
--  @function           p.taglet
--  @param[opt]         {table} options Configuration options.
--  @return             {table} Module documentation data.
function p.taglet(options)
	options = options or {}
	options.file = options.file or DEFAULT_TITLE

	options.filepath = options.filepath or (mw.site.namespaces[828].name .. ':' .. options.file)
	local filepath = options.filepath
	local content
	if options.content then
		-- unstrip nowiki for js preview
		content = lib.unstripNoWiki(options.content)
	else
		content = mw.title.new(filepath):getContent()
	end

	-- Content checks.
	if not content then
		--  @error {string} 'Lua source code not found in $1'
		error(i18n:msg('no-content', filepath))
	end
	if
		not content:match('%-%-%-') and
		not content:match(options.colon and '%s+%w+:' or '%s+@%w+')
	then
		--  @error {string} 'documentation markup for Docbunto not found in $1'
		error(i18n:msg('no-markup', filepath))
	end

	-- Remove leading escapes.
	content = content:gsub('^%-%-+%s*<[^>]+>\n', '')

	-- Remove closing pretty comments.
	content = content:gsub('\n%-%-%-%-%-+(\n[^-]+)', '\n-- %1')

	-- Remove boilerplate block comments.
	if options.boilerplate then
		content = content:gsub('^%-%-%[=*%[\n.-\n%-?%-?%]%=*]%-?%-?%s+', '')
		content = content:gsub('%s+%-%-%[=*%[\n.-\n%-?%-?%]%=*]%-?%-?$', '')
	end

	-- Configure patterns for colon mode and Unicode character encoding.
	options.unicode = type(content:find('[^%w%c%p%s]+')) == 'number'
	options.iso639_th = type(content:find('\224\184[\129-\155]')) == 'number'
	configure_patterns(options)

	-- Content lexing.
	local lines = lexer(content)
	local tokens = {}
	local dummy_token = {
		data = '',
		posFirst = 1,
		posLast = 1
	}
	local token_closure = 0
	for _, line in ipairs(lines) do
		if #line == 0 then
			dummy_token.type = token_closure == 0
				and 'whitespace'
				or  tokens[#tokens].type
			table.insert(tokens, mw.clone(dummy_token))
		else
			for _, token in ipairs(line) do
				 if token.data:find('^%[=*%[$') or token.data:find('^%-%-%[=*%[$') then
					token_closure = 1
				end
				if token.data:find(']=*]') then
					token_closure = 0
				end
				table.insert(tokens, token)
			end
		end
	end

	-- Start documentation data.
	local documentation = {}
	documentation.filename = filepath
	documentation.description = ''
	-- documentation.comments = {}
	documentation.tags = {}
	documentation.items = {}
	documentation.explicitly_exported = {}
	documentation.exported_items = {}
	documentation.other_items = {}
	documentation.private_items = {}
	local line_no = 0
	local item_index = 0

	-- Taglet tracking variables.
	local start_mode = true
	local comment_mode = false
	local doctag_mode = false
	local export_mode = false
	local special_tag = false
	local detachable_tag = false
	local comment_tail = ''
	local tag_name = ''
	local new_item = false
	local new_tag = false
	local new_item_code = false
	local code_block = false
	local pretty_comment = false
	local comment_brace = false

	local t, i = tokens[1], 1

	local status, err = pcall(function()

	while t do
		-- Taglet variable update.
		new_item = t.data:find('^%-%-%-') or t.data:find('^%-%-%[=*%[')
		comment_tail = t.data:gsub('^%-%-%[=*%[', ''):gsub('^%-%-+', '')
		tag_name = comment_tail:match(DOCBUNTO_TAG)
		tag_name = p.tags._alias[tag_name] or tag_name
		new_tag = p.tags[tag_name]
		pretty_comment =
			t.data:find('[^-]+%-%-+%s*$')  or -- what is this matching?
			t.data:find('</?nowiki>')      or
			t.data:find('</?pre>')
		comment_brace =
			t.data:find('^%-%-%[=*%[$') or
			t.data:find('^%s*%-%-%]=*%]$') or
			t.data:find('^%s*%]=*%]%-%-$%s*') or
			t.data:find('^%s*%]=*%]$')
		pragma_mode = tag_name == 'pragma'
		export_mode = tag_name == 'export'
		special_tag = pragma_mode or export_mode
		detachable_tag = tag_name == 'error'
		local tags -- FIXME storing this as a variable is silly

		-- Line counter.
		if t.posFirst == 1 then
			line_no = line_no + 1
		end

		-- Data insertion logic.
		if t.type == 'comment' then
			if new_item then comment_mode = true end

			-- Module-level documentation taglet.
			if start_mode then
				-- table.insert(documentation.comments, t.data)

				if comment_mode and not new_tag and not doctag_mode and not comment_brace and not pretty_comment then
					documentation.description = append_description(documentation.description, comment_tail)
				end

				if new_tag and not special_tag then
					doctag_mode = true
					table.insert(documentation.tags, process_tag(comment_tail, line_no))

				elseif doctag_mode and not comment_brace and not pretty_comment then
					tags = documentation.tags
					if p.tags[tags[#tags].category] == TAG_MULTI or p.tags[tags[#tags].category] == TAG_NAMED_MULTI then
						tags[#tags].value = append_description(tags[#tags].value, comment_tail)
					elseif p.tags[tags[#tags].category] == TAG_MULTI_LINE then
						tags[#tags].value = tags[#tags].value .. '\n' .. comment_tail
					end
				end
			end

			-- Documentation item detection.
			if not start_mode and (new_item or (new_tag and tokens[i - 1].type ~= 'comment')) and not special_tag and not detachable_tag then
				table.insert(documentation.items, {})
				item_index = item_index + 1
				documentation.items[item_index].lineno = line_no
				documentation.items[item_index].head = {} -- first line of tokens
				-- documentation.items[item_index].comments = {}
				documentation.items[item_index].description = ''
				documentation.items[item_index].tags = {}
			end

			if not start_mode and comment_mode and not new_tag and not doctag_mode and not comment_brace and not pretty_comment then
				documentation.items[item_index].description = append_description(
					documentation.items[item_index].description,
					comment_tail)
			end

			if not start_mode and new_tag and not special_tag then
				doctag_mode = true
				table.insert(documentation.items[item_index].tags, process_tag(comment_tail, line_no))

			elseif not start_mode and doctag_mode and not comment_brace and not pretty_comment then
				tags = documentation.items[item_index].tags
				if p.tags[tags[#tags].category] == TAG_MULTI or p.tags[tags[#tags].category] == TAG_NAMED_MULTI then
					tags[#tags].value = append_description(tags[#tags].value, comment_tail)
				elseif p.tags[tags[#tags].category] == TAG_MULTI_LINE then
					tags[#tags].value = tags[#tags].value .. '\n' .. comment_tail
				end
			end

			-- if not start_mode and (comment_mode or doctag_mode) then
			-- 	table.insert(documentation.items[item_index].comments, t.data)
			-- end

			-- Export tag support.
			
			-- Note: string-keyed tables are not supported. (E.g., {['my string'] = foo})
			if export_mode then
				-- assume that an @export tag at the start of the line must be exporting the module
				local factory_mode = t.posFirst ~= 1
				if factory_mode then -- NOTE this information doesn't seem to be used
					documentation.items[item_index].exports = true
				else
					documentation.exports = true
				end
				
				-- Lua language constructs that can contain , ; = tokens
				local BLOCKS = {
					['if'] = {terminator='end'},
					['do'] = {terminator='end'}, -- also handles 'while'
					['repeat'] = {terminator='until'}, -- the part after 'until' is an expression, so it can't have , ; = without being another block
					['for'] = {terminator='end', replace='do'},
					['function'] = {terminator='end'},
					['{'] = {terminator='}'},
					['('] = {terminator=')'},
				}

				-- look ahead, collecting all entries in the next "block" (assumed to be a table)
				local items = {}
				local si = i+1
				local st = tokens[si]
				local depth = 0
				local blocks = {} -- stack of blocks and {} ()
				local curr_item = {value={}}
				while st do
					local subtokens
					if st.type == 'symbol' then
						-- break up merged symbols into individual characters (e.g., '{}' or '{},')
						subtokens = lib.split(st.data, "")
					else
						subtokens = {st.data}
					end
					
					for _, sst in ipairs(subtokens) do
						-- Note: can't have parentheses or other "blocks" around the table
						if depth == 1 then
							-- apparently, semicolons are valid table item separators in Lua
							if sst == ',' or sst == ';' or sst == '}' then
								table.insert(items, curr_item)
								curr_item = {value={}}
							elseif sst == '=' then
								curr_item.alias = curr_item.value
								curr_item.value = {}
							elseif st.type == 'ident' or st.type == 'symbol' then
								if curr_item.value then
									table.insert(curr_item.value, sst)
								end
							end
						end
						
						if depth > 0 and sst == blocks[depth].terminator then
							blocks[depth] = nil
							depth = depth - 1
							if depth <= 0 then
								break
							end
						elseif depth > 0 and sst == blocks[depth].replace then
							blocks[depth] = blocks[depth].replace
						elseif BLOCKS[sst] then
							depth = depth + 1
							blocks[depth] = BLOCKS[sst]
							if depth > 1 then
								-- don't store value if it's a function or table; ideally, we only want identifiers
								curr_item.value = nil
							end
						end
					end
					
					st, si = tokens[si + 1], si + 1
				end
				
				local sequence_counter = 0
				for _, item in ipairs(items) do
					local item_reference, item_alias
					if item.value then
						item_reference = table.concat(item.value)
					end
					
					if item.alias then
						item_alias = table.concat(item.alias)
					else
						sequence_counter = sequence_counter + 1
						item_alias = '[' .. sequence_counter .. ']'
					end
					
					-- item_reference will be nil if it's an inline function or table
					item_reference = item_reference or item_alias
					if item_reference then
						export_item(documentation, item_reference, item_index, item_alias, factory_mode)
					end
				end
			end

			-- Pragma tag support.
			if pragma_mode then
				tags = process_tag(comment_tail, line_no)
				options[tags.value] = yesno((next(tags.modifiers)), true)
				if options[tags.value] == nil then
					options[tags.value] = true
				end
			end

		-- Data insertion logic.
		elseif comment_mode or doctag_mode then
			if item_index ~= 0 then
				new_item_code = true
			end

			-- Documentation block reset.
			start_mode = false
			comment_mode = false
			doctag_mode = false
			export_mode = false
			pragma_mode = false
		end

		-- Save item head for static analysis.
		if item_index ~= 0 and not doctag_mode and not comment_mode and new_item_code then
			local head = documentation.items[item_index].head
			if t.posFirst == 1 and isNotEmpty(head) then
				new_item_code = false
			else
				table.insert(head, t)
			end
		end

		t, i = tokens[i + 1], i + 1
	end

	documentation.lineno = line_no
	
	-- post-processing
	
	-- Package data post-processing.
	postprocess_tags(documentation.tags)
	documentation.name = extract_name(documentation, content)
	documentation.info = extract_info(documentation)
	documentation.category, documentation.type = extract_type(documentation) or 'module'
	documentation.description = lib.trim(documentation.description)
	if #documentation.description ~= 0 then
		documentation.summary = match(documentation.description, DOCBUNTO_SUMMARY)
	end
	documentation.executable = p.tags._code_types[documentation.category] and true or false
	correct_subitem_tag(documentation)
	override_item_tag(documentation, 'name')
	override_item_tag(documentation, 'alias')
	override_item_tag(documentation, 'summary')
	override_item_tag(documentation, 'description')
	override_item_tag(documentation, 'class', 'category')
	
	-- Item data post-processing.
	
	-- First pass: basic data extraction from tags and static code analysis
	for _, item in ipairs(documentation.items) do
		postprocess_tags(item.tags)
		item.name = extract_name(item)
		item.category, item.type = extract_type(item)
		item.description = lib.trim(item.description)
		if #item.description ~= 0 then
			item.summary = match(item.description, DOCBUNTO_SUMMARY)
		end
		
		code_static_analysis(item)
	end
	
	-- Second pass: explicit exports and implied locals
	-- (This loop requires tags to be processed and names to be set for all items before it runs.)
	for _, item in ipairs(documentation.items) do
		local export_data = documentation.explicitly_exported[item.name]
		if export_data then
			local parent_index = export_data.parent_index
			local item_alias = export_data.alias
			if parent_index > 0 then
				local parent = documentation.items[parent_index]
				item.alias =
					parent.tags['factory'][1].value ..
					(item_alias:find('^%[') and '' or (isInstanceFunction(item) and ':' or '.')) ..
					item_alias
			else
				item.alias =
					((documentation.tags['alias'] or {}).value or documentation.name) ..
					(item_alias:find('^%[') and '' or (documentation.category == 'classmod' and isInstanceFunction(item) and ':' or '.')) ..
					item_alias
			end
			-- TODO is hierarchy even necessary? should it always prioritize alias over base name?
			item.hierarchy = mw.text.split((item.alias:gsub('["\']?%]', '')), '[.:%[\'""]+')
			if item.category == 'variable' then
				item.category = 'member'
			end
		else
			-- never imply local for items explicitly exported with @export
			if (item.implied_local or item.category == 'variable') and not (
				item.tags['private']
				or item.tags['local']
				or item.category == 'type'
				or (item.alias or item.name):find('[.[:]')
				-- functions/tables that belong to the module or a type should not be local
			) then
				local implied_local = process_tag('@local')
				table.insert(item.tags, implied_local)
				item.tags['local'] = {implied_local}
			end
		end
	end

	-- third pass: update package name alias, process override tags (TODO does this really need its own loop?)
	local package_name, package_alias
	if documentation.tags['alias'] then
		package_name = documentation.tags['alias'][1].value
		package_alias = package_name
	else
		package_name = documentation.name
		package_alias = 'p'
	end
	local export_ptn = '^%s([.[])'

	for _, item in ipairs(documentation.items) do
		if item.name == package_alias or (item.name and item.name:match('^' .. package_alias .. '[.[]')) then
			item.alias = item.name:gsub(export_ptn:format(package_alias), documentation.name .. '%1')
		end
		if
			item.name == package_name or
			(item.name and item.name:find(export_ptn:format(package_name))) or
			(item.alias and item.alias:find(export_ptn:format(package_name)))
		then
			item.export = true -- TODO merge into previous if check?
		end
		if item.name and (item.name:find('[.:]') or item.name:find('%[[\'"]')) then
			item.hierarchy = mw.text.split((item.name:gsub('["\']?%]', '')), '[.:%[\'""]+')
		end
		item.category = item.category or ((item.alias or item.name or ''):find('[.[]') and 'member' or 'variable')
		correct_subitem_tag(item)
		override_item_tag(item, 'name')
		override_item_tag(item, 'alias')
		override_item_tag(item, 'summary')
		override_item_tag(item, 'description')
		override_item_tag(item, 'class', 'category')
	end

	-- Categorize items: exported, other, or private (local)
	for _, item in ipairs(documentation.items) do
		if item.category == 'section' then
			-- sections default to exported, or private if explicitly marked as such.
			-- (sections are never "other.")
			if item.tags['local'] or item.tags['private'] then
				table.insert(documentation.private_items, item)
			else
				table.insert(documentation.exported_items, item)
			end
		else
			if item.export then
				table.insert(documentation.exported_items, item)
			elseif item.tags['local'] or item.tags['private'] then
				table.insert(documentation.private_items, item)
			else
				table.insert(documentation.other_items, item)
			end
		end
	end

	end)
	
	if not status then
		documentation.error = err
	end
	
	return documentation
end

local function render_visibility_level(root, items, options, header_type)
	if isNotEmpty(items) then
		root = root:tag('div'):addClass(VISIBILITYLEVEL_CLASS):newline()
		root:wikitext('=== ' .. i18n:msg(header_type) .. ' ==='):newline()
	else
		return
	end
	
	local codedoc = root -- TODO rename codedoc
	for _, item in ipairs(items) do
		local itemdoc
		if item.category == 'section' then
			if item.name == 'end' then
				codedoc = root
			else
				codedoc = root:tag('div'):addClass(SECTION_CLASS):newline()
				codedoc:wikitext('==== ', mw.ustring.gsub(item.alias or maybe_md(item.name) or maybe_md(item.summary), '[.։。।෴۔።]$', ''), ' ===='):newline()
				if #item.description ~= 0 then
					codedoc:wikitext(maybe_md(item.description)):newline()
				end
			end
		elseif item.category == 'type' then
			if item.name == 'end' then
				codedoc = root
			else
				codedoc = root:tag('div'):addClass(SECTION_CLASS):newline()
				codedoc:wikitext('==== <code>', (item.alias or item.name), '</code> ===='):newline()
				if #item.description ~= 0 then
					codedoc:wikitext(maybe_md(item.description)):newline()
				end
			end
		elseif item.category == 'function' then
			itemdoc = render_item(codedoc, item, options, preop_function_name)
			if not options.simple and item.tags['param'] then
				render_tag(itemdoc, 'param', item.tags['param'], options)
			end
			if not options.simple and item.tags['error'] then
				render_tag(itemdoc, 'error', item.tags['error'], options, preop_error_line)
			end
			if not options.simple and item.tags['return'] then
				render_tag(itemdoc, 'return', item.tags['return'], options)
			end

		elseif
			item.category == 'table' or item.category == 'member' or item.category == 'variable'
		then
			itemdoc = render_item(codedoc, item, options)
			if not options.simple and item.tags['field'] then
				render_tag(itemdoc, 'field', item.tags['field'], options)
			end
		end

		local target = itemdoc or codedoc -- itemdoc doesn't exist for sections (item.category of "section" or "type")
		
		if not options.simple and item.tags['note'] then
			render_tag(target, 'note', item.tags['note'], options)
		end
		if not options.simple and item.tags['warning'] then
			render_tag(target, 'warning', item.tags['warning'], options)
		end
		if not options.simple and item.tags['fixme'] then
			render_tag(target, 'fixme', item.tags['fixme'], options)
		end
		if not options.simple and item.tags['todo'] then
			render_tag(target, 'todo', item.tags['todo'], options)
		end
		if not options.simple and item.tags['usage'] then
			render_tag(target, 'usage', item.tags['usage'], options, preop_usage_highlight)
		end
		if not options.simple and item.tags['see'] then
			render_tag(target, 'see', item.tags['see'], options)
		end
	end
	return root
end

--- Doclet renderer for Docbunto taglet data.
--  @function           p.doclet
--  @param              {table} data Taglet documentation data.
--  @param[opt]         {table} options Configuration options.
--  @return             {string} Wikitext documentation output.
function p.doclet(data, options)
	local documentation = mw.html.create('div'):addClass('docbunto')
	
	if data.error then
		documentation:wikitext("Error during documentation parsing: ", data.error):newline():newline()
	end
	
	local namespace = '^' .. mw.site.namespaces[828].name .. ':'
	local codepage = data.filename:gsub(namespace, '')

	options = options or {}
	options.filepath = options.filepath or data.filename -- janky way to pass filepath to `render_item`
	frame = frame or mw.getCurrentFrame():getParent() or mw.getCurrentFrame() -- parent is nil in dev console

	-- Enable/disable markdown based on options
	configure_markdown(options)

	-- Disable edit sections for automatic documentation pages.
	if not options.code then
		documentation:wikitext('__NOEDITSECTION__')
	end

	-- Custom infobox for external wikis.
	if not options.code then
		local custom, infobox = pcall(require, 'Module:Docbunto/infobox')
		if custom and type(infobox) == 'function' then
			documentation:wikitext(infobox(data, codepage, frame, options)):newline()
		end
	end

	-- Documentation lede.
	if not options.code and #data.description ~= 0 then
		local intro = maybe_md(data.description:gsub('^(' .. codepage .. ')', '<b>%1</b>'))
		documentation:wikitext(intro):newline():newline()
	end

	-- Custom documentation preface.
	if options.preface then
		documentation:wikitext(options.preface):newline():newline()
	end

	-- Start code documentation.
	local function_module = data.tags['param'] or data.tags['return']
	
	if (function_module or isNotEmpty(data.items)) and not options.code or options.preface then
		documentation:wikitext('== ', i18n:msg('header-documentation'), ' =='):newline()
	end

	-- Render documentation items.
	if function_module then
		data.category = 'function'
		if not options.code then data.description = '' end
		render_visibility_level(documentation, {data}, options, 'header-function')
	else
		local header_type = documentation.category == 'classmod' and 'header-class' or 'header-items'
		render_visibility_level(documentation, data.exported_items, options, header_type)
	end
	
	render_visibility_level(documentation, data.other_items, options, 'header-other')
	
	if options.all then
		render_visibility_level(documentation, data.private_items, options, 'header-private')
	end
	
	-- Render module-level annotations.
	local header_paren = options.code and '===' or '=='
	local header_text
	for _, tag_name in ipairs{'warning', 'fixme', 'note', 'todo', 'see'} do -- TODO add module-level table with this order; maybe add usage?
		if data.tags[tag_name] then
			header_text =  i18n:msg('tag-' .. tag_name, data.tags[tag_name].value and '1' or '2')
			header_text = header_paren .. ' ' .. header_text .. ' ' .. header_paren
			documentation:newline():wikitext(header_text):newline()
			for _, tag_el in ipairs(data.tags[tag_name]) do
				documentation:wikitext('* ', maybe_md(tag_el.value)):newline()
			end
		end
	end

	return documentation
end

--- Token dictionary for Docbunto tags.
--  Maps Docbunto tag names to tag tokens.
--   * Multi-line tags use the `'M'` token.
--   * Multi-line preformatted tags use the `'ML'` token.
--   * Identifier tags use the `'ID'` token.
--   * Single-line tags use the `'S'` token.
--   * Flags use the `'N'` token.
--   * Type tags use the `'T'` token.
--  @table              p.tags
p.tags = {
	-- Item-level tags, available for global use.
	['param'] = 'NM', ['see'] = 'M', ['note'] = 'M', ['usage'] = 'ML',
	['description'] = 'M', ['field'] = 'NM', ['return'] = 'M',
	['fixme'] = 'M', ['todo'] = 'M', ['warning'] = 'M', ['error'] = 'M';
	['class'] = 'ID', ['name'] = 'ID', ['alias'] = 'ID';
	['summary'] = 'S', ['pragma'] = 'S', ['factory'] = 'S',
	['release'] = 'S', ['author'] = 'S', ['copyright'] = 'S', ['license'] = 'S',
	['image'] = 'S', ['caption'] = 'S', ['require'] = 'S', ['attribution'] = 'S',
	['credit'] = 'S', ['demo'] = 'S';
	['local'] = 'N', ['export'] = 'N', ['private'] = 'N', ['constructor'] = 'N',
	['static'] = 'N';
	-- Project-level tags, all scoped to a file.
	['module'] = 'T', ['script'] = 'T', ['classmod'] = 'T', ['topic'] = 'T',
	['submodule'] = 'T', ['example'] = 'T', ['file'] = 'T';
	-- Module-level tags, used to register module items.
	['function'] = 'T', ['table'] = 'T', ['member'] = 'T', ['variable'] = 'T',
	['section'] = 'T', ['type'] = 'T';
}
p.tags._alias = {
	-- Normal aliases.
	['about']       = 'summary',
	['abstract']    = 'summary',
	['brief']       = 'summary',
	['bug']         = 'fixme',
	['argument']    = 'param',
	['credits']     = 'credit',
	['code']        = 'usage',
	['details']     = 'description',
	['discussion']  = 'description',
	['exception']   = 'error',
	['lfunction']   = 'function',
	['package']     = 'module',
	['property']    = 'member',
	['raise']       = 'error',
	['requires']    = 'require',
	['returns']     = 'return',
	['throws']      = 'error',
	['typedef']     = 'type',
	-- Typed aliases.
	['bool']        = 'field',
	['func']        = 'field',
	['int']         = 'field',
	['number']      = 'field',
	['string']      = 'field',
	['tab']         = 'field',
	['vararg']      = 'param',
	['tfield']      = 'field',
	['tparam']      = 'param',
	['treturn']     = 'return'
}
p.tags._type_alias = {
	-- Implicit type value alias.
	['bool']        = 'boolean',
	['func']        = 'function',
	['int']         = 'number',
	['number']      = 'number',
	['string']      = 'string',
	['tab']         = 'table',
	['vararg']      = '...',
	-- Pure typed modifier alias.
	['tfield']      = 'variable',
	['tparam']      = 'variable',
	['treturn']     = 'variable'
}
p.tags._project_level = {
	-- Contains code.
	['module']      = true,
	['script']      = true,
	['classmod']    = true,
	['submodule']   = true,
	['file']        = true,
	-- Contains documentation.
	['topic']       = true,
	['example']     = true
}
p.tags._code_types = {
	['module']      = true,
	['script']      = true,
	['classmod']    = true
}
p.tags._module_info = {
	['image']       = true,
	['caption']     = true,
	['release']     = true,
	['author']      = true,
	['copyright']   = true,
	['license']     = true,
	['require']     = true,
	['credit']      = true,
	['attribution'] = true,
	['demo']        = true
}
p.tags._annotation_tags = {
	['warning']     = true,
	['fixme']       = true,
	['note']        = true,
	['todo']        = true,
	['see']         = true
}
p.tags._privacy_tags = {
	['private']     = true,
	['local']       = true
}
p.tags._generic_tags = {
	['variable']    = true,
	['member']      = true
}
p.tags._subtype_tags = {
	['factory']     = true,
	['local']       = true,
	['private']     = true,
	['constructor'] = true,
	['static']      = true
}
p.tags._subtype_hierarchy = {
	'private',
	'local',
	'static',
	'factory',
	'constructor'
}

return p