Module:Docbunto

Revision as of 15:11, 2 September 2026 by ReisuDesign (talk | contribs)
Module documentation
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 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.

Documentation

Package items

docbunto.main(f)(function)

Template entrypoint for Template:Docbunto.

Parameter:

(table) f Scribunto frame object.

Returns:

(string) Module documentation output.

docbunto.build([options])(function)

Scribunto documentation generator entrypoint.

Parameters:

(table;optional) options Configuration options.

Keys:

(string;optional) options.file Module page name (without namespace). Default: second-level subpage.

(string;optional) options.content An parameter used for debugging this module. Pass in a string containing source code to view the documentation it would generate.

(boolean;optional) options.all Include local items in documentation.

(boolean;optional) options.boilerplate Removal of boilerplate (license block comments).

(string;optional) options.caption Infobox image caption.

(boolean;optional) options.code Only document Docbunto code items - exclude article infobox and lede from rendered documentation. Permits article to be edited in VisualEditor.

(boolean;optional) options.colon Format tags with a : suffix and without the @ prefix. This bypasses the "doctag soup" some authors complain of.

(string;optional) options.image Infobox image.

(boolean;optional) options.noluaref Don't link to the Lua reference manual for types.

(boolean;optional) options.plain Disable Markdown formatting in documentation.

(string;optional) options.preface Preface text to insert between lede & item documentation, used to provide usage and code examples.

(boolean;optional) options.simple Limit documentation to descriptions only. Removes documentation of subitem tags such as @param and @field (see list).

(boolean;optional) options.strip Remove table index in documentation.

docbunto.taglet([options])(function)

Docbunto taglet parser for Scribunto modules.

Parameter:

(table;optional) options Configuration options.

Errors:

(string;line 918) 'Lua source code not found in $1'

(string;line 925) 'documentation markup for Docbunto not found in $1'

Returns:

(table) Module documentation data.

docbunto.doclet(data, [options])(function)

Doclet renderer for Docbunto taglet data.

Parameters:

(table) data Taglet documentation data.

(table;optional) options Configuration options.

Returns:

(string) Wikitext documentation output.

docbunto.tags(table)

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.
--- 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('Module:I18n').loadMessages('Module:Docbunto/i18n')
local references = mw.loadData('Module:Docbunto/references')
local lexer = require('Module:Lexer')
local unindent = require('Module:Unindent')
local yesno = require('Module: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