gen_help_html.lua 41 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111
  1. -- Converts Vim :help files to HTML. Validates |tag| links and document syntax (parser errors).
  2. --
  3. -- NOTE: :helptags checks for duplicate tags, whereas this script checks _links_ (to tags).
  4. --
  5. -- USAGE (GENERATE HTML):
  6. -- 1. Run `make helptags` first; this script depends on vim.fn.taglist().
  7. -- 2. nvim -V1 -es --clean +"lua require('scripts.gen_help_html').gen('./build/runtime/doc/', 'target/dir/')"
  8. -- - Read the docstring at gen().
  9. -- 3. cd target/dir/ && jekyll serve --host 0.0.0.0
  10. -- 4. Visit http://localhost:4000/…/help.txt.html
  11. --
  12. -- USAGE (VALIDATE):
  13. -- 1. nvim -V1 -es +"lua require('scripts.gen_help_html').validate()"
  14. -- - validate() is 10x faster than gen(), so it is used in CI.
  15. --
  16. -- SELF-TEST MODE:
  17. -- 1. nvim -V1 -es +"lua require('scripts.gen_help_html')._test()"
  18. --
  19. -- NOTES:
  20. -- * gen() and validate() are the primary entrypoints. validate() only exists because gen() is too
  21. -- slow (~1 min) to run in per-commit CI.
  22. -- * visit_node() is the core function used by gen() to traverse the document tree and produce HTML.
  23. -- * visit_validate() is the core function used by validate().
  24. -- * Files in `new_layout` will be generated with a "flow" layout instead of preformatted/fixed-width layout.
  25. local tagmap = nil
  26. local helpfiles = nil
  27. local invalid_links = {}
  28. local invalid_urls = {}
  29. local invalid_spelling = {}
  30. local spell_dict = {
  31. Neovim = 'Nvim',
  32. NeoVim = 'Nvim',
  33. neovim = 'Nvim',
  34. lua = 'Lua',
  35. VimL = 'Vimscript',
  36. }
  37. local language = nil
  38. local M = {}
  39. -- These files are generated with "flow" layout (non fixed-width, wrapped text paragraphs).
  40. -- All other files are "legacy" files which require fixed-width layout.
  41. local new_layout = {
  42. ['api.txt'] = true,
  43. ['channel.txt'] = true,
  44. ['deprecated.txt'] = true,
  45. ['develop.txt'] = true,
  46. ['lua.txt'] = true,
  47. ['luaref.txt'] = true,
  48. ['news.txt'] = true,
  49. ['nvim.txt'] = true,
  50. ['pi_health.txt'] = true,
  51. ['provider.txt'] = true,
  52. ['ui.txt'] = true,
  53. }
  54. -- TODO: These known invalid |links| require an update to the relevant docs.
  55. local exclude_invalid = {
  56. ["'previewpopup'"] = "quickref.txt",
  57. ["'pvp'"] = "quickref.txt",
  58. ["'string'"] = "eval.txt",
  59. Query = 'treesitter.txt',
  60. ['eq?'] = 'treesitter.txt',
  61. ['lsp-request'] = 'lsp.txt',
  62. matchit = 'vim_diff.txt',
  63. ['matchit.txt'] = 'help.txt',
  64. ["set!"] = "treesitter.txt",
  65. ['v:_null_blob'] = 'builtin.txt',
  66. ['v:_null_dict'] = 'builtin.txt',
  67. ['v:_null_list'] = 'builtin.txt',
  68. ['v:_null_string'] = 'builtin.txt',
  69. ['vim.lsp.buf_request()'] = 'lsp.txt',
  70. ['vim.lsp.util.get_progress_messages()'] = 'lsp.txt',
  71. }
  72. -- False-positive "invalid URLs".
  73. local exclude_invalid_urls = {
  74. ["http://"] = "usr_23.txt",
  75. ["http://."] = "usr_23.txt",
  76. ["http://aspell.net/man-html/Affix-Compression.html"] = "spell.txt",
  77. ["http://aspell.net/man-html/Phonetic-Code.html"] = "spell.txt",
  78. ["http://canna.sourceforge.jp/"] = "mbyte.txt",
  79. ["http://gnuada.sourceforge.net"] = "ft_ada.txt",
  80. ["http://lua-users.org/wiki/StringLibraryTutorial"] = "lua.txt",
  81. ["http://michael.toren.net/code/"] = "pi_tar.txt",
  82. ["http://papp.plan9.de"] = "syntax.txt",
  83. ["http://wiki.services.openoffice.org/wiki/Dictionaries"] = "spell.txt",
  84. ["http://www.adapower.com"] = "ft_ada.txt",
  85. ["http://www.jclark.com/"] = "quickfix.txt",
  86. }
  87. local function tofile(fname, text)
  88. local f = io.open(fname, 'w')
  89. if not f then
  90. error(('failed to write: %s'):format(f))
  91. else
  92. f:write(text)
  93. f:close()
  94. end
  95. end
  96. local function html_esc(s)
  97. return s:gsub(
  98. '&', '&'):gsub(
  99. '<', '&lt;'):gsub(
  100. '>', '&gt;')
  101. end
  102. local function url_encode(s)
  103. -- Credit: tpope / vim-unimpaired
  104. -- NOTE: these chars intentionally *not* escaped: ' ( )
  105. return vim.fn.substitute(vim.fn.iconv(s, 'latin1', 'utf-8'),
  106. [=[[^A-Za-z0-9()'_.~-]]=],
  107. [=[\="%".printf("%02X",char2nr(submatch(0)))]=],
  108. 'g')
  109. end
  110. local function expandtabs(s)
  111. return s:gsub('\t', (' '):rep(8))
  112. end
  113. local function to_titlecase(s)
  114. local text = ''
  115. for w in vim.gsplit(s, '[ \t]+') do
  116. text = ('%s %s%s'):format(text, vim.fn.toupper(w:sub(1, 1)), w:sub(2))
  117. end
  118. return text
  119. end
  120. local function to_heading_tag(text)
  121. -- Prepend "_" to avoid conflicts with actual :help tags.
  122. return text and string.format('_%s', vim.fn.tolower((text:gsub('%s+', '-')))) or 'unknown'
  123. end
  124. local function basename_noext(f)
  125. return vim.fs.basename(f:gsub('%.txt', ''))
  126. end
  127. local function is_blank(s)
  128. return not not s:find([[^[\t ]*$]])
  129. end
  130. local function trim(s, dir)
  131. return vim.fn.trim(s, '\r\t\n ', dir or 0)
  132. end
  133. -- Remove common punctuation from URLs.
  134. --
  135. -- TODO: fix this in the parser instead... https://github.com/neovim/tree-sitter-vimdoc
  136. --
  137. -- @returns (fixed_url, removed_chars) where `removed_chars` is in the order found in the input.
  138. local function fix_url(url)
  139. local removed_chars = ''
  140. local fixed_url = url
  141. -- Remove up to one of each char from end of the URL, in this order.
  142. for _, c in ipairs({ '.', ')', }) do
  143. if fixed_url:sub(-1) == c then
  144. removed_chars = c .. removed_chars
  145. fixed_url = fixed_url:sub(1, -2)
  146. end
  147. end
  148. return fixed_url, removed_chars
  149. end
  150. -- Checks if a given line is a "noise" line that doesn't look good in HTML form.
  151. local function is_noise(line, noise_lines)
  152. if (
  153. -- First line is always noise.
  154. (noise_lines ~= nil and vim.tbl_count(noise_lines) == 0)
  155. or line:find('Type .*gO.* to see the table of contents')
  156. -- Title line of traditional :help pages.
  157. -- Example: "NVIM REFERENCE MANUAL by ..."
  158. or line:find([[^%s*N?VIM[ \t]*REFERENCE[ \t]*MANUAL]])
  159. -- First line of traditional :help pages.
  160. -- Example: "*api.txt* Nvim"
  161. or line:find('%s*%*?[a-zA-Z]+%.txt%*?%s+N?[vV]im%s*$')
  162. -- modeline
  163. -- Example: "vim:tw=78:ts=8:sw=4:sts=4:et:ft=help:norl:"
  164. or line:find('^%s*vim?%:.*ft=help')
  165. or line:find('^%s*vim?%:.*filetype=help')
  166. or line:find('[*>]local%-additions[*<]')
  167. ) then
  168. -- table.insert(stats.noise_lines, getbuflinestr(root, opt.buf, 0))
  169. table.insert(noise_lines or {}, line)
  170. return true
  171. end
  172. return false
  173. end
  174. -- Creates a github issue URL at neovim/tree-sitter-vimdoc with prefilled content.
  175. local function get_bug_url_vimdoc(fname, to_fname, sample_text)
  176. local this_url = string.format('https://neovim.io/doc/user/%s', vim.fs.basename(to_fname))
  177. local bug_url = ('https://github.com/neovim/tree-sitter-vimdoc/issues/new?labels=bug&title=parse+error%3A+'
  178. ..vim.fs.basename(fname)
  179. ..'+&body=Found+%60tree-sitter-vimdoc%60+parse+error+at%3A+'
  180. ..this_url
  181. ..'%0D%0DContext%3A%0D%0D%60%60%60%0D'
  182. ..url_encode(sample_text)
  183. ..'%0D%60%60%60')
  184. return bug_url
  185. end
  186. -- Creates a github issue URL at neovim/neovim with prefilled content.
  187. local function get_bug_url_nvim(fname, to_fname, sample_text, token_name)
  188. local this_url = string.format('https://neovim.io/doc/user/%s', vim.fs.basename(to_fname))
  189. local bug_url = ('https://github.com/neovim/neovim/issues/new?labels=bug&title=user+docs+HTML%3A+'
  190. ..vim.fs.basename(fname)
  191. ..'+&body=%60gen_help_html.lua%60+problem+at%3A+'
  192. ..this_url
  193. ..'%0D'
  194. ..(token_name and '+unhandled+token%3A+%60'..token_name..'%60' or '')
  195. ..'%0DContext%3A%0D%0D%60%60%60%0D'
  196. ..url_encode(sample_text)
  197. ..'%0D%60%60%60')
  198. return bug_url
  199. end
  200. -- Gets a "foo.html" name from a "foo.txt" helpfile name.
  201. local function get_helppage(f)
  202. if not f then
  203. return nil
  204. end
  205. -- Special case: help.txt is the "main landing page" of :help files, not index.txt.
  206. if f == 'index.txt' then
  207. return 'vimindex.html'
  208. elseif f == 'help.txt' then
  209. return 'index.html'
  210. end
  211. return (f:gsub('%.txt$', '.html'))
  212. end
  213. -- Counts leading spaces (tab=8) to decide the indent size of multiline text.
  214. --
  215. -- Blank lines (empty or whitespace-only) are ignored.
  216. local function get_indent(s)
  217. local min_indent = nil
  218. for line in vim.gsplit(s, '\n') do
  219. if line and not is_blank(line) then
  220. local ws = expandtabs(line:match('^%s+') or '')
  221. min_indent = (not min_indent or ws:len() < min_indent) and ws:len() or min_indent
  222. end
  223. end
  224. return min_indent or 0
  225. end
  226. -- Removes the common indent level, after expanding tabs to 8 spaces.
  227. local function trim_indent(s)
  228. local indent_size = get_indent(s)
  229. local trimmed = ''
  230. for line in vim.gsplit(s, '\n') do
  231. line = expandtabs(line)
  232. trimmed = ('%s%s\n'):format(trimmed, line:sub(indent_size + 1))
  233. end
  234. return trimmed:sub(1, -2)
  235. end
  236. -- Gets raw buffer text in the node's range (+/- an offset), as a newline-delimited string.
  237. local function getbuflinestr(node, bufnr, offset)
  238. local line1, _, line2, _ = node:range()
  239. line1 = line1 - offset
  240. line2 = line2 + offset
  241. local lines = vim.fn.getbufline(bufnr, line1 + 1, line2 + 1)
  242. return table.concat(lines, '\n')
  243. end
  244. -- Gets the whitespace just before `node` from the raw buffer text.
  245. -- Needed for preformatted `old` lines.
  246. local function getws(node, bufnr)
  247. local line1, c1, line2, _ = node:range()
  248. local raw = vim.fn.getbufline(bufnr, line1 + 1, line2 + 1)[1]
  249. local text_before = raw:sub(1, c1)
  250. local leading_ws = text_before:match('%s+$') or ''
  251. return leading_ws
  252. end
  253. local function get_tagname(node, bufnr)
  254. local text = vim.treesitter.get_node_text(node, bufnr)
  255. local tag = (node:type() == 'optionlink' or node:parent():type() == 'optionlink') and ("'%s'"):format(text) or text
  256. local helpfile = vim.fs.basename(tagmap[tag]) or nil -- "api.txt"
  257. local helppage = get_helppage(helpfile) -- "api.html"
  258. return helppage, tag
  259. end
  260. -- Returns true if the given invalid tagname is a false positive.
  261. local function ignore_invalid(s)
  262. return not not (
  263. exclude_invalid[s]
  264. -- Strings like |~/====| appear in various places and the parser thinks they are links, but they
  265. -- are just table borders.
  266. or s:find('===')
  267. or s:find('---')
  268. )
  269. end
  270. local function ignore_parse_error(s)
  271. return (
  272. -- Ignore parse errors for unclosed tag.
  273. -- This is common in vimdocs and is treated as plaintext by :help.
  274. s:find("^[`'|*]")
  275. )
  276. end
  277. local function has_ancestor(node, ancestor_name)
  278. local p = node
  279. while true do
  280. p = p:parent()
  281. if not p or p:type() == 'help_file' then
  282. break
  283. elseif p:type() == ancestor_name then
  284. return true
  285. end
  286. end
  287. return false
  288. end
  289. -- Gets the first matching child node matching `name`.
  290. local function first(node, name)
  291. for c, _ in node:iter_children() do
  292. if c:named() and c:type() == name then
  293. return c
  294. end
  295. end
  296. return nil
  297. end
  298. local function validate_link(node, bufnr, fname)
  299. local helppage, tagname = get_tagname(node:child(1), bufnr)
  300. local ignored = false
  301. if not tagmap[tagname] then
  302. ignored = has_ancestor(node, 'column_heading') or node:has_error() or ignore_invalid(tagname)
  303. if not ignored then
  304. invalid_links[tagname] = vim.fs.basename(fname)
  305. end
  306. end
  307. return helppage, tagname, ignored
  308. end
  309. -- TODO: port the logic from scripts/check_urls.vim
  310. local function validate_url(text, fname)
  311. local ignored = false
  312. if vim.fs.basename(fname) == 'pi_netrw.txt' then
  313. ignored = true
  314. elseif text:find('http%:') and not exclude_invalid_urls[text] then
  315. invalid_urls[text] = vim.fs.basename(fname)
  316. end
  317. return ignored
  318. end
  319. -- Traverses the tree at `root` and checks that |tag| links point to valid helptags.
  320. local function visit_validate(root, level, lang_tree, opt, stats)
  321. level = level or 0
  322. local node_name = (root.named and root:named()) and root:type() or nil
  323. local toplevel = level < 1
  324. local function node_text(node)
  325. return vim.treesitter.get_node_text(node or root, opt.buf)
  326. end
  327. local text = trim(node_text())
  328. if root:child_count() > 0 then
  329. for node, _ in root:iter_children() do
  330. if node:named() then
  331. visit_validate(node, level + 1, lang_tree, opt, stats)
  332. end
  333. end
  334. end
  335. if node_name == 'ERROR' then
  336. if ignore_parse_error(text) then
  337. return
  338. end
  339. -- Store the raw text to give context to the error report.
  340. local sample_text = not toplevel and getbuflinestr(root, opt.buf, 3) or '[top level!]'
  341. table.insert(stats.parse_errors, sample_text)
  342. elseif node_name == 'word' or node_name == 'uppercase_name' then
  343. if spell_dict[text] then
  344. if not invalid_spelling[text] then
  345. invalid_spelling[text] = { vim.fs.basename(opt.fname) }
  346. else
  347. table.insert(invalid_spelling[text], vim.fs.basename(opt.fname))
  348. end
  349. end
  350. elseif node_name == 'url' then
  351. local fixed_url, _ = fix_url(trim(text))
  352. validate_url(fixed_url, opt.fname)
  353. elseif node_name == 'taglink' or node_name == 'optionlink' then
  354. local _, _, _ = validate_link(root, opt.buf, opt.fname)
  355. end
  356. end
  357. -- Fix tab alignment issues caused by concealed characters like |, `, * in tags
  358. -- and code blocks.
  359. local function fix_tab_after_conceal(text, next_node_text)
  360. -- Vim tabs take into account the two concealed characters even though they
  361. -- are invisible, so we need to add back in the two spaces if this is
  362. -- followed by a tab to make the tab alignment to match Vim's behavior.
  363. if string.sub(next_node_text,1,1) == '\t' then
  364. text = text .. ' '
  365. end
  366. return text
  367. end
  368. -- Generates HTML from node `root` recursively.
  369. local function visit_node(root, level, lang_tree, headings, opt, stats)
  370. level = level or 0
  371. local node_name = (root.named and root:named()) and root:type() or nil
  372. -- Previous sibling kind (string).
  373. local prev = root:prev_sibling() and (root:prev_sibling().named and root:prev_sibling():named()) and root:prev_sibling():type() or nil
  374. -- Next sibling kind (string).
  375. local next_ = root:next_sibling() and (root:next_sibling().named and root:next_sibling():named()) and root:next_sibling():type() or nil
  376. -- Parent kind (string).
  377. local parent = root:parent() and root:parent():type() or nil
  378. local text = ''
  379. local trimmed
  380. -- Gets leading whitespace of `node`.
  381. local function ws(node)
  382. node = node or root
  383. local ws_ = getws(node, opt.buf)
  384. -- XXX: first node of a (line) includes whitespace, even after
  385. -- https://github.com/neovim/tree-sitter-vimdoc/pull/31 ?
  386. if ws_ == '' then
  387. ws_ = vim.treesitter.get_node_text(node, opt.buf):match('^%s+') or ''
  388. end
  389. return ws_
  390. end
  391. local function node_text(node, ws_)
  392. node = node or root
  393. ws_ = (ws_ == nil or ws_ == true) and getws(node, opt.buf) or ''
  394. return string.format('%s%s', ws_, vim.treesitter.get_node_text(node, opt.buf))
  395. end
  396. if root:named_child_count() == 0 or node_name == 'ERROR' then
  397. text = node_text()
  398. trimmed = html_esc(trim(text))
  399. text = html_esc(text)
  400. else
  401. -- Process children and join them with whitespace.
  402. for node, _ in root:iter_children() do
  403. if node:named() then
  404. local r = visit_node(node, level + 1, lang_tree, headings, opt, stats)
  405. text = string.format('%s%s', text, r)
  406. end
  407. end
  408. trimmed = trim(text)
  409. end
  410. if node_name == 'help_file' then -- root node
  411. return text
  412. elseif node_name == 'url' then
  413. local fixed_url, removed_chars = fix_url(trimmed)
  414. return ('%s<a href="%s">%s</a>%s'):format(ws(), fixed_url, fixed_url, removed_chars)
  415. elseif node_name == 'word' or node_name == 'uppercase_name' then
  416. return text
  417. elseif node_name == 'h1' or node_name == 'h2' or node_name == 'h3' then
  418. if is_noise(text, stats.noise_lines) then
  419. return '' -- Discard common "noise" lines.
  420. end
  421. -- Remove "===" and tags from ToC text.
  422. local hname = (node_text():gsub('%-%-%-%-+', ''):gsub('%=%=%=%=+', ''):gsub('%*.*%*', ''))
  423. -- Use the first *tag* node as the heading anchor, if any.
  424. local tagnode = first(root, 'tag')
  425. local tagname = tagnode and url_encode(node_text(tagnode:child(1), false)) or to_heading_tag(hname)
  426. if node_name == 'h1' or #headings == 0 then
  427. table.insert(headings, { name = hname, subheadings = {}, tag = tagname })
  428. else
  429. table.insert(headings[#headings].subheadings, { name = hname, subheadings = {}, tag = tagname })
  430. end
  431. local el = node_name == 'h1' and 'h2' or 'h3'
  432. -- If we are re-using the *tag*, this heading anchor is redundant.
  433. local a = tagnode and '' or ('<a name="%s"></a>'):format(tagname)
  434. return ('%s<%s class="help-heading">%s</%s>\n'):format(a, el, text, el)
  435. elseif node_name == 'column_heading' or node_name == 'column_name' then
  436. if root:has_error() then
  437. return text
  438. end
  439. return ('<div class="help-column_heading">%s</div>'):format(text)
  440. elseif node_name == 'block' then
  441. if is_blank(text) then
  442. return ''
  443. end
  444. if opt.old then
  445. -- XXX: Treat "old" docs as preformatted: they use indentation for layout.
  446. -- Trim trailing newlines to avoid too much whitespace between divs.
  447. return ('<div class="old-help-para">%s</div>\n'):format(trim(text, 2))
  448. end
  449. return string.format('<div class="help-para">\n%s\n</div>\n', text)
  450. elseif node_name == 'line' then
  451. if (parent ~= 'codeblock' or parent ~= 'code') and (is_blank(text) or is_noise(text, stats.noise_lines)) then
  452. return '' -- Discard common "noise" lines.
  453. end
  454. -- XXX: Avoid newlines (too much whitespace) after block elements in old (preformatted) layout.
  455. local div = opt.old and root:child(0) and vim.tbl_contains({'column_heading', 'h1', 'h2', 'h3'}, root:child(0):type())
  456. return string.format('%s%s', div and trim(text) or text, div and '' or '\n')
  457. elseif node_name == 'line_li' then
  458. local sib = root:prev_sibling()
  459. local prev_li = sib and sib:type() == 'line_li'
  460. if not prev_li then
  461. opt.indent = 1
  462. else
  463. -- The previous listitem _sibling_ is _logically_ the _parent_ if it is indented less.
  464. local parent_indent = get_indent(node_text(sib))
  465. local this_indent = get_indent(node_text())
  466. if this_indent > parent_indent then
  467. opt.indent = opt.indent + 1
  468. elseif this_indent < parent_indent then
  469. opt.indent = math.max(1, opt.indent - 1)
  470. end
  471. end
  472. local margin = opt.indent == 1 and '' or ('margin-left: %drem;'):format((1.5 * opt.indent))
  473. return string.format('<div class="help-li" style="%s">%s</div>', margin, text)
  474. elseif node_name == 'taglink' or node_name == 'optionlink' then
  475. local helppage, tagname, ignored = validate_link(root, opt.buf, opt.fname)
  476. if ignored then
  477. return text
  478. end
  479. local s = ('%s<a href="%s#%s">%s</a>'):format(ws(), helppage, url_encode(tagname), html_esc(tagname))
  480. if opt.old and node_name == 'taglink' then
  481. s = fix_tab_after_conceal(s, node_text(root:next_sibling()))
  482. end
  483. return s
  484. elseif vim.tbl_contains({'codespan', 'keycode'}, node_name) then
  485. if root:has_error() then
  486. return text
  487. end
  488. local s = ('%s<code>%s</code>'):format(ws(), trimmed)
  489. if opt.old and node_name == 'codespan' then
  490. s = fix_tab_after_conceal(s, node_text(root:next_sibling()))
  491. end
  492. return s
  493. elseif node_name == 'argument' then
  494. return ('%s<code>{%s}</code>'):format(ws(), text)
  495. elseif node_name == 'codeblock' then
  496. return text
  497. elseif node_name == 'language' then
  498. language = node_text(root)
  499. return ''
  500. elseif node_name == 'code' then
  501. if is_blank(text) then
  502. return ''
  503. end
  504. local code
  505. if language then
  506. code = ('<pre><code class="language-%s">%s</code></pre>'):format(language,trim(trim_indent(text), 2))
  507. language = nil
  508. else
  509. code = ('<pre>%s</pre>'):format(trim(trim_indent(text), 2))
  510. end
  511. return code
  512. elseif node_name == 'tag' then -- anchor
  513. if root:has_error() then
  514. return text
  515. end
  516. local in_heading = vim.tbl_contains({'h1', 'h2', 'h3'}, parent)
  517. local cssclass = (not in_heading and get_indent(node_text()) > 8) and 'help-tag-right' or 'help-tag'
  518. local tagname = node_text(root:child(1), false)
  519. if vim.tbl_count(stats.first_tags) < 2 then
  520. -- Force the first 2 tags in the doc to be anchored at the main heading.
  521. table.insert(stats.first_tags, tagname)
  522. return ''
  523. end
  524. local el = in_heading and 'span' or 'code'
  525. local s = ('%s<a name="%s"></a><%s class="%s">%s</%s>'):format(ws(), url_encode(tagname), el, cssclass, trimmed, el)
  526. if opt.old then
  527. s = fix_tab_after_conceal(s, node_text(root:next_sibling()))
  528. end
  529. if in_heading and prev ~= 'tag' then
  530. -- Start the <span> container for tags in a heading.
  531. -- This makes "justify-content:space-between" right-align the tags.
  532. -- <h2>foo bar<span>tag1 tag2</span></h2>
  533. return string.format('<span class="help-heading-tags">%s', s)
  534. elseif in_heading and next_ == nil then
  535. -- End the <span> container for tags in a heading.
  536. return string.format('%s</span>', s)
  537. end
  538. return s
  539. elseif node_name == 'ERROR' then
  540. if ignore_parse_error(trimmed) then
  541. return text
  542. end
  543. -- Store the raw text to give context to the bug report.
  544. local sample_text = level > 0 and getbuflinestr(root, opt.buf, 3) or '[top level!]'
  545. table.insert(stats.parse_errors, sample_text)
  546. return ('<a class="parse-error" target="_blank" title="Report bug... (parse error)" href="%s">%s</a>'):format(
  547. get_bug_url_vimdoc(opt.fname, opt.to_fname, sample_text), trimmed)
  548. else -- Unknown token.
  549. local sample_text = level > 0 and getbuflinestr(root, opt.buf, 3) or '[top level!]'
  550. return ('<a class="unknown-token" target="_blank" title="Report bug... (unhandled token "%s")" href="%s">%s</a>'):format(
  551. node_name, get_bug_url_nvim(opt.fname, opt.to_fname, sample_text, node_name), trimmed), ('unknown-token:"%s"'):format(node_name)
  552. end
  553. end
  554. local function get_helpfiles(include)
  555. local dir = './build/runtime/doc'
  556. local rv = {}
  557. for f, type in vim.fs.dir(dir) do
  558. if (vim.endswith(f, '.txt')
  559. and type == 'file'
  560. and (not include or vim.tbl_contains(include, f))) then
  561. local fullpath = vim.fn.fnamemodify(('%s/%s'):format(dir, f), ':p')
  562. table.insert(rv, fullpath)
  563. end
  564. end
  565. return rv
  566. end
  567. -- Populates the helptags map.
  568. local function get_helptags(help_dir)
  569. local m = {}
  570. -- Load a random help file to convince taglist() to do its job.
  571. vim.cmd(string.format('split %s/api.txt', help_dir))
  572. vim.cmd('lcd %:p:h')
  573. for _, item in ipairs(vim.fn.taglist('.*')) do
  574. if vim.endswith(item.filename, '.txt') then
  575. m[item.name] = item.filename
  576. end
  577. end
  578. vim.cmd('q!')
  579. return m
  580. end
  581. -- Use the vimdoc parser defined in the build, not whatever happens to be installed on the system.
  582. local function ensure_runtimepath()
  583. if not vim.o.runtimepath:find('build/lib/nvim/') then
  584. vim.cmd[[set runtimepath^=./build/lib/nvim/]]
  585. end
  586. end
  587. -- Opens `fname` in a buffer and gets a treesitter parser for the buffer contents.
  588. --
  589. -- @returns lang_tree, bufnr
  590. local function parse_buf(fname)
  591. local buf
  592. if type(fname) == 'string' then
  593. vim.cmd('split '..vim.fn.fnameescape(fname)) -- Filename.
  594. buf = vim.api.nvim_get_current_buf()
  595. else
  596. buf = fname
  597. vim.cmd('sbuffer '..tostring(fname)) -- Buffer number.
  598. end
  599. -- vim.treesitter.require_language('help', './build/lib/nvim/parser/vimdoc.so')
  600. local lang_tree = vim.treesitter.get_parser(buf)
  601. return lang_tree, buf
  602. end
  603. -- Validates one :help file `fname`:
  604. -- - checks that |tag| links point to valid helptags.
  605. -- - recursively counts parse errors ("ERROR" nodes)
  606. --
  607. -- @returns { invalid_links: number, parse_errors: number }
  608. local function validate_one(fname)
  609. local stats = {
  610. parse_errors = {},
  611. }
  612. local lang_tree, buf = parse_buf(fname)
  613. for _, tree in ipairs(lang_tree:trees()) do
  614. visit_validate(tree:root(), 0, tree, { buf = buf, fname = fname, }, stats)
  615. end
  616. lang_tree:destroy()
  617. vim.cmd.close()
  618. return stats
  619. end
  620. -- Generates HTML from one :help file `fname` and writes the result to `to_fname`.
  621. --
  622. -- @param fname Source :help file
  623. -- @param to_fname Destination .html file
  624. -- @param old boolean Preformat paragraphs (for old :help files which are full of arbitrary whitespace)
  625. --
  626. -- @returns html, stats
  627. local function gen_one(fname, to_fname, old, commit)
  628. local stats = {
  629. noise_lines = {},
  630. parse_errors = {},
  631. first_tags = {}, -- Track the first few tags in doc.
  632. }
  633. local lang_tree, buf = parse_buf(fname)
  634. local headings = {} -- Headings (for ToC). 2-dimensional: h1 contains h2/h3.
  635. local title = to_titlecase(basename_noext(fname))
  636. local html = ([[
  637. <!DOCTYPE html>
  638. <html>
  639. <head>
  640. <meta charset="utf-8">
  641. <meta http-equiv="X-UA-Compatible" content="IE=edge">
  642. <meta name="viewport" content="width=device-width, initial-scale=1">
  643. <meta name="description" content="Neovim user documentation">
  644. <link href="/css/normalize.min.css" rel="stylesheet">
  645. <link href="/css/bootstrap.css" rel="stylesheet">
  646. <link href="/css/main.css" rel="stylesheet">
  647. <link href="help.css" rel="stylesheet">
  648. <link href="/highlight/styles/neovim.min.css" rel="stylesheet">
  649. <script src="/highlight/highlight.min.js"></script>
  650. <script>hljs.highlightAll();</script>
  651. <title>%s - Neovim docs</title>
  652. </head>
  653. <body>
  654. ]]):format(title)
  655. local logo_svg = [[
  656. <svg xmlns="http://www.w3.org/2000/svg" role="img" width="173" height="50" viewBox="0 0 742 214" aria-label="Neovim">
  657. <title>Neovim</title>
  658. <defs>
  659. <linearGradient x1="50%" y1="0%" x2="50%" y2="100%" id="a">
  660. <stop stop-color="#16B0ED" stop-opacity=".8" offset="0%" />
  661. <stop stop-color="#0F59B2" stop-opacity=".837" offset="100%" />
  662. </linearGradient>
  663. <linearGradient x1="50%" y1="0%" x2="50%" y2="100%" id="b">
  664. <stop stop-color="#7DB643" offset="0%" />
  665. <stop stop-color="#367533" offset="100%" />
  666. </linearGradient>
  667. <linearGradient x1="50%" y1="0%" x2="50%" y2="100%" id="c">
  668. <stop stop-color="#88C649" stop-opacity=".8" offset="0%" />
  669. <stop stop-color="#439240" stop-opacity=".84" offset="100%" />
  670. </linearGradient>
  671. </defs>
  672. <g fill="none" fill-rule="evenodd">
  673. <path
  674. d="M.027 45.459L45.224-.173v212.171L.027 166.894V45.459z"
  675. fill="url(#a)"
  676. transform="translate(1 1)"
  677. />
  678. <path
  679. d="M129.337 45.89L175.152-.149l-.928 212.146-45.197-45.104.31-121.005z"
  680. fill="url(#b)"
  681. transform="matrix(-1 0 0 1 305 1)"
  682. />
  683. <path
  684. d="M45.194-.137L162.7 179.173l-32.882 32.881L12.25 33.141 45.194-.137z"
  685. fill="url(#c)"
  686. transform="translate(1 1)"
  687. />
  688. <path
  689. d="M46.234 84.032l-.063 7.063-36.28-53.563 3.36-3.422 32.983 49.922z"
  690. fill-opacity=".13"
  691. fill="#000"
  692. />
  693. <g fill="#444">
  694. <path
  695. d="M227 154V64.44h4.655c1.55 0 2.445.75 2.685 2.25l.806 13.502c4.058-5.16 8.786-9.316 14.188-12.466 5.4-3.15 11.413-4.726 18.037-4.726 4.893 0 9.205.781 12.935 2.34 3.729 1.561 6.817 3.811 9.264 6.751 2.448 2.942 4.297 6.48 5.55 10.621 1.253 4.14 1.88 8.821 1.88 14.042V154h-8.504V96.754c0-8.402-1.91-14.987-5.729-19.757-3.82-4.771-9.667-7.156-17.544-7.156-5.851 0-11.28 1.516-16.292 4.545-5.013 3.032-9.489 7.187-13.427 12.467V154H227zM350.624 63c5.066 0 9.755.868 14.069 2.605 4.312 1.738 8.052 4.268 11.219 7.592s5.638 7.412 7.419 12.264C385.11 90.313 386 95.883 386 102.17c0 1.318-.195 2.216-.588 2.696-.393.48-1.01.719-1.851.719h-64.966v1.70c0 6.708.784 12.609 2.353 17.7 1.567 5.09 3.8 9.357 6.695 12.802 2.895 3.445 6.393 6.034 10.495 7.771 4.1 1.738 8.686 2.606 13.752 2.606 4.524 0 8.446-.494 11.762-1.483 3.317-.988 6.108-2.097 8.37-3.324 2.261-1.227 4.056-2.336 5.383-3.324 1.326-.988 2.292-1.482 2.895-1.482.784 0 1.388.3 1.81.898l2.352 2.875c-1.448 1.797-3.362 3.475-5.745 5.031-2.383 1.558-5.038 2.891-7.962 3.998-2.926 1.109-6.062 1.991-9.41 2.65a52.21 52.21 0 01-10.088.989c-6.152 0-11.762-1.064-16.828-3.19-5.067-2.125-9.415-5.225-13.043-9.298-3.63-4.074-6.435-9.06-8.415-14.96C310.99 121.655 310 114.9 310 107.294c0-6.408.92-12.323 2.76-17.744 1.84-5.421 4.493-10.093 7.961-14.016 3.467-3.922 7.72-6.991 12.758-9.209C338.513 64.11 344.229 63 350.624 63zm.573 6c-4.696 0-8.904.702-12.623 2.105-3.721 1.404-6.936 3.421-9.65 6.053-2.713 2.631-4.908 5.79-6.586 9.474S319.55 94.439 319 99h60c0-4.679-.672-8.874-2.013-12.588-1.343-3.712-3.232-6.856-5.67-9.43-2.44-2.571-5.367-4.545-8.782-5.92-3.413-1.374-7.192-2.062-11.338-2.062zM435.546 63c6.526 0 12.368 1.093 17.524 3.28 5.154 2.186 9.5 5.286 13.04 9.298 3.538 4.013 6.238 8.85 8.099 14.51 1.861 5.66 2.791 11.994 2.791 19.002 0 7.008-.932 13.327-2.791 18.957-1.861 5.631-4.561 10.452-8.099 14.465-3.54 4.012-7.886 7.097-13.04 9.254-5.156 2.156-10.998 3.234-17.524 3.234-6.529 0-12.369-1.078-17.525-3.234-5.155-2.157-9.517-5.242-13.085-9.254-3.57-4.013-6.285-8.836-8.145-14.465-1.861-5.63-2.791-11.95-2.791-18.957 0-7.008.93-13.342 2.791-19.002 1.861-5.66 4.576-10.496 8.145-14.51 3.568-4.012 7.93-7.112 13.085-9.299C423.177 64.094 429.017 63 435.546 63zm-.501 86c5.341 0 10.006-.918 13.997-2.757 3.99-1.838 7.32-4.474 9.992-7.909 2.67-3.435 4.664-7.576 5.986-12.428 1.317-4.85 1.98-10.288 1.98-16.316 0-5.965-.66-11.389-1.98-16.27-1.322-4.88-3.316-9.053-5.986-12.519-2.67-3.463-6-6.13-9.992-7.999-3.991-1.867-8.657-2.802-13.997-2.802s-10.008.935-13.997 2.802c-3.991 1.87-7.322 4.536-9.992 8-2.671 3.465-4.68 7.637-6.03 12.518-1.35 4.881-2.026 10.305-2.026 16.27 0 6.026.675 11.465 2.025 16.316 1.35 4.852 3.36 8.993 6.031 12.428 2.67 3.435 6 6.07 9.992 7.91 3.99 1.838 8.656 2.756 13.997 2.756z"
  696. fill="currentColor"
  697. />
  698. <path
  699. d="M530.57 152h-20.05L474 60h18.35c1.61 0 2.967.39 4.072 1.166 1.103.778 1.865 1.763 2.283 2.959l17.722 49.138a92.762 92.762 0 012.551 8.429c.686 2.751 1.298 5.5 1.835 8.25.537-2.75 1.148-5.499 1.835-8.25a77.713 77.713 0 012.64-8.429l18.171-49.138c.417-1.196 1.164-2.181 2.238-2.96 1.074-.776 2.356-1.165 3.849-1.165H567l-36.43 92zM572 61h23v92h-23zM610 153V60.443h13.624c2.887 0 4.78 1.354 5.682 4.06l1.443 6.856a52.7 52.7 0 015.097-4.962 32.732 32.732 0 015.683-3.879 30.731 30.731 0 016.496-2.57c2.314-.632 4.855-.948 7.624-.948 5.832 0 10.63 1.579 14.39 4.736 3.758 3.157 6.57 7.352 8.434 12.585 1.444-3.068 3.248-5.698 5.413-7.894 2.165-2.194 4.541-3.984 7.127-5.367a32.848 32.848 0 018.254-3.068 39.597 39.597 0 018.796-.992c5.111 0 9.653.783 13.622 2.345 3.97 1.565 7.307 3.849 10.014 6.857 2.706 3.007 4.766 6.675 6.18 11.005C739.29 83.537 740 88.5 740 94.092V153h-22.284V94.092c0-5.894-1.294-10.329-3.878-13.306-2.587-2.977-6.376-4.465-11.368-4.465-2.286 0-4.404.391-6.358 1.172a15.189 15.189 0 00-5.144 3.383c-1.473 1.474-2.631 3.324-3.474 5.548-.842 2.225-1.263 4.781-1.263 7.668V153h-22.37V94.092c0-6.194-1.249-10.704-3.744-13.532-2.497-2.825-6.18-4.24-11.051-4.24-3.19 0-6.18.798-8.976 2.391-2.799 1.593-5.399 3.775-7.804 6.54V153H610zM572 30h23v19h-23z"
  700. fill="currentColor"
  701. fill-opacity=".8"
  702. />
  703. </g>
  704. </g>
  705. </svg>
  706. ]]
  707. local main = ''
  708. for _, tree in ipairs(lang_tree:trees()) do
  709. main = main .. (visit_node(tree:root(), 0, tree, headings,
  710. { buf = buf, old = old, fname = fname, to_fname = to_fname, indent = 1, },
  711. stats))
  712. end
  713. main = ([[
  714. <header class="container">
  715. <nav class="navbar navbar-expand-lg">
  716. <div>
  717. <a href="/" class="navbar-brand" aria-label="logo">
  718. <!--TODO: use <img src="….svg"> here instead. Need one that has green lettering instead of gray. -->
  719. %s
  720. <!--<img src="https://neovim.io/logos/neovim-logo.svg" width="173" height="50" alt="Neovim" />-->
  721. </a>
  722. </div>
  723. </nav>
  724. </header>
  725. <div class="container golden-grid help-body">
  726. <div class="col-wide">
  727. <a name="%s"></a><a name="%s"></a><h1>%s</h1>
  728. <p>
  729. <i>
  730. Nvim <code>:help</code> pages, <a href="https://github.com/neovim/neovim/blob/master/scripts/gen_help_html.lua">generated</a>
  731. from <a href="https://github.com/neovim/neovim/blob/master/runtime/doc/%s">source</a>
  732. using the <a href="https://github.com/neovim/tree-sitter-vimdoc">tree-sitter-vimdoc</a> parser.
  733. </i>
  734. </p>
  735. <hr/>
  736. %s
  737. </div>
  738. ]]):format(logo_svg, stats.first_tags[1] or '', stats.first_tags[2] or '', title, vim.fs.basename(fname), main)
  739. local toc = [[
  740. <div class="col-narrow toc">
  741. <div><a href="index.html">Main</a></div>
  742. <div><a href="vimindex.html">Commands index</a></div>
  743. <div><a href="quickref.html">Quick reference</a></div>
  744. <hr/>
  745. ]]
  746. local n = 0 -- Count of all headings + subheadings.
  747. for _, h1 in ipairs(headings) do n = n + 1 + #h1.subheadings end
  748. for _, h1 in ipairs(headings) do
  749. toc = toc .. ('<div class="help-toc-h1"><a href="#%s">%s</a>\n'):format(h1.tag, h1.name)
  750. if n < 30 or #headings < 10 then -- Show subheadings only if there aren't too many.
  751. for _, h2 in ipairs(h1.subheadings) do
  752. toc = toc .. ('<div class="help-toc-h2"><a href="#%s">%s</a></div>\n'):format(h2.tag, h2.name)
  753. end
  754. end
  755. toc = toc .. '</div>'
  756. end
  757. toc = toc .. '</div>\n'
  758. local bug_url = get_bug_url_nvim(fname, to_fname, 'TODO', nil)
  759. local bug_link = string.format('(<a href="%s" target="_blank">report docs bug...</a>)', bug_url)
  760. local footer = ([[
  761. <footer>
  762. <div class="container flex">
  763. <div class="generator-stats">
  764. Generated at %s from <code><a href="https://github.com/neovim/neovim/commit/%s">%s</a></code>
  765. </div>
  766. <div class="generator-stats">
  767. parse_errors: %d %s | <span title="%s">noise_lines: %d</span>
  768. </div>
  769. <div>
  770. </footer>
  771. ]]):format(
  772. os.date('%Y-%m-%d %H:%M'), commit, commit:sub(1, 7), #stats.parse_errors, bug_link,
  773. html_esc(table.concat(stats.noise_lines, '\n')), #stats.noise_lines)
  774. html = ('%s%s%s</div>\n%s</body>\n</html>\n'):format(
  775. html, main, toc, footer)
  776. vim.cmd('q!')
  777. lang_tree:destroy()
  778. return html, stats
  779. end
  780. local function gen_css(fname)
  781. local css = [[
  782. :root {
  783. --code-color: #004b4b;
  784. --tag-color: #095943;
  785. }
  786. @media (prefers-color-scheme: dark) {
  787. :root {
  788. --code-color: #00c243;
  789. --tag-color: #00b7b7;
  790. }
  791. }
  792. @media (min-width: 40em) {
  793. .toc {
  794. position: fixed;
  795. left: 67%;
  796. }
  797. .golden-grid {
  798. display: grid;
  799. grid-template-columns: 65% auto;
  800. grid-gap: 1em;
  801. }
  802. }
  803. @media (max-width: 40em) {
  804. .golden-grid {
  805. /* Disable grid for narrow viewport (mobile phone). */
  806. display: block;
  807. }
  808. }
  809. .toc {
  810. /* max-width: 12rem; */
  811. height: 85%; /* Scroll if there are too many items. https://github.com/neovim/neovim.github.io/issues/297 */
  812. overflow: auto; /* Scroll if there are too many items. https://github.com/neovim/neovim.github.io/issues/297 */
  813. }
  814. .toc > div {
  815. text-overflow: ellipsis;
  816. overflow: hidden;
  817. white-space: nowrap;
  818. }
  819. html {
  820. scroll-behavior: auto;
  821. }
  822. body {
  823. font-size: 18px;
  824. line-height: 1.5;
  825. }
  826. h1, h2, h3, h4, h5 {
  827. font-family: sans-serif;
  828. border-bottom: 1px solid var(--tag-color); /*rgba(0, 0, 0, .9);*/
  829. }
  830. h3, h4, h5 {
  831. border-bottom-style: dashed;
  832. }
  833. .help-column_heading {
  834. color: var(--code-color);
  835. }
  836. .help-body {
  837. padding-bottom: 2em;
  838. }
  839. .help-line {
  840. /* font-family: ui-monospace,SFMono-Regular,SF Mono,Menlo,Consolas,Liberation Mono,monospace; */
  841. }
  842. .help-li {
  843. white-space: normal;
  844. display: list-item;
  845. margin-left: 1.5rem; /* padding-left: 1rem; */
  846. }
  847. .help-para {
  848. padding-top: 10px;
  849. padding-bottom: 10px;
  850. }
  851. .old-help-para {
  852. padding-top: 10px;
  853. padding-bottom: 10px;
  854. /* Tabs are used for alignment in old docs, so we must match Vim's 8-char expectation. */
  855. tab-size: 8;
  856. white-space: pre;
  857. font-size: 16px;
  858. font-family: ui-monospace,SFMono-Regular,SF Mono,Menlo,Consolas,Liberation Mono,monospace;
  859. }
  860. a.help-tag, a.help-tag:focus, a.help-tag:hover {
  861. color: inherit;
  862. text-decoration: none;
  863. }
  864. .help-tag {
  865. color: var(--tag-color);
  866. }
  867. /* Tag pseudo-header common in :help docs. */
  868. .help-tag-right {
  869. color: var(--tag-color);
  870. }
  871. h1 .help-tag, h2 .help-tag, h3 .help-tag {
  872. font-size: smaller;
  873. }
  874. .help-heading {
  875. overflow: hidden;
  876. white-space: nowrap;
  877. display: flex;
  878. justify-content: space-between;
  879. }
  880. /* The (right-aligned) "tags" part of a section heading. */
  881. .help-heading-tags {
  882. margin-right: 10px;
  883. }
  884. .help-toc-h1 {
  885. }
  886. .help-toc-h2 {
  887. margin-left: 1em;
  888. }
  889. .parse-error {
  890. background-color: red;
  891. }
  892. .unknown-token {
  893. color: black;
  894. background-color: yellow;
  895. }
  896. code {
  897. color: var(--code-color);
  898. font-size: 16px;
  899. }
  900. pre {
  901. /* Tabs are used in codeblocks only for indentation, not alignment, so we can aggressively shrink them. */
  902. tab-size: 2;
  903. white-space: pre-wrap;
  904. line-height: 1.3; /* Important for ascii art. */
  905. overflow: visible;
  906. /* font-family: ui-monospace,SFMono-Regular,SF Mono,Menlo,Consolas,Liberation Mono,monospace; */
  907. font-size: 16px;
  908. margin-top: 10px;
  909. }
  910. pre:hover,
  911. .help-heading:hover {
  912. overflow: visible;
  913. }
  914. .generator-stats {
  915. color: gray;
  916. font-size: smaller;
  917. }
  918. ]]
  919. tofile(fname, css)
  920. end
  921. function M._test()
  922. tagmap = get_helptags('./build/runtime/doc')
  923. helpfiles = get_helpfiles()
  924. local function ok(cond, expected, actual)
  925. assert((not expected and not actual) or (expected and actual), 'if "expected" is given, "actual" is also required')
  926. if expected then
  927. return assert(cond, ('expected %s, got: %s'):format(vim.inspect(expected), vim.inspect(actual)))
  928. else
  929. return assert(cond)
  930. end
  931. end
  932. local function eq(expected, actual)
  933. return ok(expected == actual, expected, actual)
  934. end
  935. ok(vim.tbl_count(tagmap) > 3000, '>3000', vim.tbl_count(tagmap))
  936. ok(vim.endswith(tagmap['vim.diagnostic.set()'], 'diagnostic.txt'), tagmap['vim.diagnostic.set()'], 'diagnostic.txt')
  937. ok(vim.endswith(tagmap['%:s'], 'cmdline.txt'), tagmap['%:s'], 'cmdline.txt')
  938. ok(is_noise([[vim:tw=78:isk=!-~,^*,^\|,^\":ts=8:noet:ft=help:norl:]]))
  939. ok(is_noise([[ NVIM REFERENCE MANUAL by Thiago de Arruda ]]))
  940. ok(not is_noise([[vim:tw=78]]))
  941. eq(0, get_indent('a'))
  942. eq(1, get_indent(' a'))
  943. eq(2, get_indent(' a\n b\n c\n'))
  944. eq(5, get_indent(' a\n \n b\n c\n d\n e\n'))
  945. eq('a\n \n b\n c\n d\n e\n', trim_indent(' a\n \n b\n c\n d\n e\n'))
  946. local fixed_url, removed_chars = fix_url('https://example.com).')
  947. eq('https://example.com', fixed_url)
  948. eq(').', removed_chars)
  949. fixed_url, removed_chars = fix_url('https://example.com.)')
  950. eq('https://example.com.', fixed_url)
  951. eq(')', removed_chars)
  952. fixed_url, removed_chars = fix_url('https://example.com.')
  953. eq('https://example.com', fixed_url)
  954. eq('.', removed_chars)
  955. fixed_url, removed_chars = fix_url('https://example.com)')
  956. eq('https://example.com', fixed_url)
  957. eq(')', removed_chars)
  958. fixed_url, removed_chars = fix_url('https://example.com')
  959. eq('https://example.com', fixed_url)
  960. eq('', removed_chars)
  961. print('all tests passed')
  962. end
  963. --- Generates HTML from :help docs located in `help_dir` and writes the result in `to_dir`.
  964. ---
  965. --- Example:
  966. ---
  967. --- gen('./build/runtime/doc', '/path/to/neovim.github.io/_site/doc/', {'api.txt', 'autocmd.txt', 'channel.txt'}, nil)
  968. ---
  969. --- @param help_dir string Source directory containing the :help files. Must run `make helptags` first.
  970. --- @param to_dir string Target directory where the .html files will be written.
  971. --- @param include table|nil Process only these filenames. Example: {'api.txt', 'autocmd.txt', 'channel.txt'}
  972. ---
  973. --- @returns info dict
  974. function M.gen(help_dir, to_dir, include, commit)
  975. vim.validate{
  976. help_dir={help_dir, function(d) return vim.fn.isdirectory(d) == 1 end, 'valid directory'},
  977. to_dir={to_dir, 's'},
  978. include={include, 't', true},
  979. commit={commit, 's', true},
  980. }
  981. local err_count = 0
  982. ensure_runtimepath()
  983. tagmap = get_helptags(help_dir)
  984. helpfiles = get_helpfiles(include)
  985. print(('output dir: %s'):format(to_dir))
  986. vim.fn.mkdir(to_dir, 'p')
  987. gen_css(('%s/help.css'):format(to_dir))
  988. for _, f in ipairs(helpfiles) do
  989. local helpfile = vim.fs.basename(f)
  990. local to_fname = ('%s/%s'):format(to_dir, get_helppage(helpfile))
  991. local html, stats = gen_one(f, to_fname, not new_layout[helpfile], commit or '?')
  992. tofile(to_fname, html)
  993. print(('generated (%-4s errors): %-15s => %s'):format(#stats.parse_errors, helpfile, vim.fs.basename(to_fname)))
  994. err_count = err_count + #stats.parse_errors
  995. end
  996. print(('generated %d html pages'):format(#helpfiles))
  997. print(('total errors: %d'):format(err_count))
  998. print(('invalid tags:\n%s'):format(vim.inspect(invalid_links)))
  999. return {
  1000. helpfiles = helpfiles,
  1001. err_count = err_count,
  1002. invalid_links = invalid_links,
  1003. }
  1004. end
  1005. -- Validates all :help files found in `help_dir`:
  1006. -- - checks that |tag| links point to valid helptags.
  1007. -- - recursively counts parse errors ("ERROR" nodes)
  1008. --
  1009. -- This is 10x faster than gen(), for use in CI.
  1010. --
  1011. -- @returns results dict
  1012. function M.validate(help_dir, include)
  1013. vim.validate{
  1014. help_dir={help_dir, function(d) return vim.fn.isdirectory(d) == 1 end, 'valid directory'},
  1015. include={include, 't', true},
  1016. }
  1017. local err_count = 0
  1018. ensure_runtimepath()
  1019. tagmap = get_helptags(help_dir)
  1020. helpfiles = get_helpfiles(include)
  1021. for _, f in ipairs(helpfiles) do
  1022. local helpfile = vim.fs.basename(f)
  1023. local rv = validate_one(f)
  1024. print(('validated (%-4s errors): %s'):format(#rv.parse_errors, helpfile))
  1025. err_count = err_count + #rv.parse_errors
  1026. end
  1027. return {
  1028. helpfiles = #helpfiles,
  1029. err_count = err_count,
  1030. invalid_links = invalid_links,
  1031. invalid_urls = invalid_urls,
  1032. invalid_spelling = invalid_spelling,
  1033. }
  1034. end
  1035. return M