gen_help_html.lua 51 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446
  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 (For CI/local testing purposes): Simply `make lintdoc` or `scripts/lintdoc.lua`, which
  6. -- basically does the following:
  7. -- 1. :helptags ALL
  8. -- 2. nvim -V1 -es +"lua require('scripts.gen_help_html').run_validate()" +q
  9. -- 3. nvim -V1 -es +"lua require('scripts.gen_help_html').test_gen()" +q
  10. --
  11. -- USAGE (GENERATE HTML):
  12. -- 1. `:helptags ALL` first; this script depends on vim.fn.taglist().
  13. -- 2. nvim -V1 -es --clean +"lua require('scripts.gen_help_html').gen('./runtime/doc', 'target/dir/')" +q
  14. -- - Read the docstring at gen().
  15. -- 3. cd target/dir/ && jekyll serve --host 0.0.0.0
  16. -- 4. Visit http://localhost:4000/…/help.txt.html
  17. --
  18. -- USAGE (VALIDATE):
  19. -- 1. nvim -V1 -es +"lua require('scripts.gen_help_html').validate('./runtime/doc')" +q
  20. -- - validate() is 10x faster than gen(), so it is used in CI.
  21. --
  22. -- SELF-TEST MODE:
  23. -- 1. nvim -V1 -es +"lua require('scripts.gen_help_html')._test()" +q
  24. --
  25. -- NOTES:
  26. -- * gen() and validate() are the primary (programmatic) entrypoints. validate() only exists
  27. -- because gen() is too slow (~1 min) to run in per-commit CI.
  28. -- * visit_node() is the core function used by gen() to traverse the document tree and produce HTML.
  29. -- * visit_validate() is the core function used by validate().
  30. -- * Files in `new_layout` will be generated with a "flow" layout instead of preformatted/fixed-width layout.
  31. local tagmap = nil ---@type table<string, string>
  32. local helpfiles = nil ---@type string[]
  33. local invalid_links = {} ---@type table<string, any>
  34. local invalid_urls = {} ---@type table<string, any>
  35. local invalid_spelling = {} ---@type table<string, table<string, string>>
  36. local spell_dict = {
  37. Neovim = 'Nvim',
  38. NeoVim = 'Nvim',
  39. neovim = 'Nvim',
  40. lua = 'Lua',
  41. VimL = 'Vimscript',
  42. vimL = 'Vimscript',
  43. viml = 'Vimscript',
  44. ['tree-sitter'] = 'treesitter',
  45. ['Tree-sitter'] = 'Treesitter',
  46. }
  47. --- specify the list of keywords to ignore (i.e. allow), or true to disable spell check completely.
  48. --- @type table<string, true|string[]>
  49. local spell_ignore_files = {
  50. ['backers.txt'] = true,
  51. ['news.txt'] = { 'tree-sitter' }, -- in news, may refer to the upstream "tree-sitter" library
  52. }
  53. local language = nil
  54. local M = {}
  55. -- These files are generated with "flow" layout (non fixed-width, wrapped text paragraphs).
  56. -- All other files are "legacy" files which require fixed-width layout.
  57. local new_layout = {
  58. ['api.txt'] = true,
  59. ['lsp.txt'] = true,
  60. ['channel.txt'] = true,
  61. ['deprecated.txt'] = true,
  62. ['develop.txt'] = true,
  63. ['dev_style.txt'] = true,
  64. ['dev_theme.txt'] = true,
  65. ['dev_tools.txt'] = true,
  66. ['dev_vimpatch.txt'] = true,
  67. ['faq.txt'] = true,
  68. ['lua.txt'] = true,
  69. ['luaref.txt'] = true,
  70. ['news.txt'] = true,
  71. ['news-0.9.txt'] = true,
  72. ['news-0.10.txt'] = true,
  73. ['nvim.txt'] = true,
  74. ['pi_health.txt'] = true,
  75. ['provider.txt'] = true,
  76. ['ui.txt'] = true,
  77. ['vim_diff.txt'] = true,
  78. }
  79. -- TODO: These known invalid |links| require an update to the relevant docs.
  80. local exclude_invalid = {
  81. ["'string'"] = 'eval.txt',
  82. Query = 'treesitter.txt',
  83. matchit = 'vim_diff.txt',
  84. ['set!'] = 'treesitter.txt',
  85. }
  86. -- False-positive "invalid URLs".
  87. local exclude_invalid_urls = {
  88. ['http://'] = 'usr_23.txt',
  89. ['http://.'] = 'usr_23.txt',
  90. ['http://aspell.net/man-html/Affix-Compression.html'] = 'spell.txt',
  91. ['http://aspell.net/man-html/Phonetic-Code.html'] = 'spell.txt',
  92. ['http://canna.sourceforge.jp/'] = 'mbyte.txt',
  93. ['http://gnuada.sourceforge.net'] = 'ft_ada.txt',
  94. ['http://lua-users.org/wiki/StringLibraryTutorial'] = 'lua.txt',
  95. ['http://michael.toren.net/code/'] = 'pi_tar.txt',
  96. ['http://papp.plan9.de'] = 'syntax.txt',
  97. ['http://wiki.services.openoffice.org/wiki/Dictionaries'] = 'spell.txt',
  98. ['http://www.adapower.com'] = 'ft_ada.txt',
  99. ['http://www.jclark.com/'] = 'quickfix.txt',
  100. ['http://oldblog.antirez.com/post/redis-and-scripting.html'] = 'faq.txt',
  101. }
  102. -- Deprecated, brain-damaged files that I don't care about.
  103. local ignore_errors = {
  104. ['pi_netrw.txt'] = true,
  105. ['backers.txt'] = true,
  106. }
  107. local function tofile(fname, text)
  108. local f = io.open(fname, 'w')
  109. if not f then
  110. error(('failed to write: %s'):format(f))
  111. else
  112. f:write(text)
  113. f:close()
  114. end
  115. end
  116. ---@type fun(s: string): string
  117. local function html_esc(s)
  118. return (s:gsub('&', '&amp;'):gsub('<', '&lt;'):gsub('>', '&gt;'))
  119. end
  120. local function url_encode(s)
  121. -- Credit: tpope / vim-unimpaired
  122. -- NOTE: these chars intentionally *not* escaped: ' ( )
  123. return vim.fn.substitute(
  124. vim.fn.iconv(s, 'latin1', 'utf-8'),
  125. [=[[^A-Za-z0-9()'_.~-]]=],
  126. [=[\="%".printf("%02X",char2nr(submatch(0)))]=],
  127. 'g'
  128. )
  129. end
  130. local function expandtabs(s)
  131. return s:gsub('\t', (' '):rep(8)) --[[ @as string ]]
  132. end
  133. local function to_titlecase(s)
  134. local text = ''
  135. for w in vim.gsplit(s, '[ \t]+') do
  136. text = ('%s %s%s'):format(text, vim.fn.toupper(w:sub(1, 1)), w:sub(2))
  137. end
  138. return text
  139. end
  140. local function to_heading_tag(text)
  141. -- Prepend "_" to avoid conflicts with actual :help tags.
  142. return text and string.format('_%s', vim.fn.tolower((text:gsub('%s+', '-')))) or 'unknown'
  143. end
  144. local function basename_noext(f)
  145. return vim.fs.basename(f:gsub('%.txt', ''))
  146. end
  147. local function is_blank(s)
  148. return not not s:find([[^[\t ]*$]])
  149. end
  150. ---@type fun(s: string, dir?:0|1|2): string
  151. local function trim(s, dir)
  152. return vim.fn.trim(s, '\r\t\n ', dir or 0)
  153. end
  154. --- Removes common punctuation from URLs.
  155. ---
  156. --- TODO: fix this in the parser instead... https://github.com/neovim/tree-sitter-vimdoc
  157. ---
  158. --- @param url string
  159. --- @return string, string (fixed_url, removed_chars) where `removed_chars` is in the order found in the input.
  160. local function fix_url(url)
  161. local removed_chars = ''
  162. local fixed_url = url
  163. -- Remove up to one of each char from end of the URL, in this order.
  164. for _, c in ipairs({ '.', ')' }) do
  165. if fixed_url:sub(-1) == c then
  166. removed_chars = c .. removed_chars
  167. fixed_url = fixed_url:sub(1, -2)
  168. end
  169. end
  170. return fixed_url, removed_chars
  171. end
  172. --- Checks if a given line is a "noise" line that doesn't look good in HTML form.
  173. local function is_noise(line, noise_lines)
  174. if
  175. -- First line is always noise.
  176. (noise_lines ~= nil and vim.tbl_count(noise_lines) == 0)
  177. or line:find('Type .*gO.* to see the table of contents')
  178. -- Title line of traditional :help pages.
  179. -- Example: "NVIM REFERENCE MANUAL by ..."
  180. or line:find([[^%s*N?VIM[ \t]*REFERENCE[ \t]*MANUAL]])
  181. -- First line of traditional :help pages.
  182. -- Example: "*api.txt* Nvim"
  183. or line:find('%s*%*?[a-zA-Z]+%.txt%*?%s+N?[vV]im%s*$')
  184. -- modeline
  185. -- Example: "vim:tw=78:ts=8:sw=4:sts=4:et:ft=help:norl:"
  186. or line:find('^%s*vim?%:.*ft=help')
  187. or line:find('^%s*vim?%:.*filetype=help')
  188. or line:find('[*>]local%-additions[*<]')
  189. then
  190. -- table.insert(stats.noise_lines, getbuflinestr(root, opt.buf, 0))
  191. table.insert(noise_lines or {}, line)
  192. return true
  193. end
  194. return false
  195. end
  196. --- Creates a github issue URL at neovim/tree-sitter-vimdoc with prefilled content.
  197. local function get_bug_url_vimdoc(fname, to_fname, sample_text)
  198. local this_url = string.format('https://neovim.io/doc/user/%s', vim.fs.basename(to_fname))
  199. local bug_url = (
  200. 'https://github.com/neovim/tree-sitter-vimdoc/issues/new?labels=bug&title=parse+error%3A+'
  201. .. vim.fs.basename(fname)
  202. .. '+&body=Found+%60tree-sitter-vimdoc%60+parse+error+at%3A+'
  203. .. this_url
  204. .. '%0D%0DContext%3A%0D%0D%60%60%60%0D'
  205. .. url_encode(sample_text)
  206. .. '%0D%60%60%60'
  207. )
  208. return bug_url
  209. end
  210. --- Creates a github issue URL at neovim/neovim with prefilled content.
  211. local function get_bug_url_nvim(fname, to_fname, sample_text, token_name)
  212. local this_url = string.format('https://neovim.io/doc/user/%s', vim.fs.basename(to_fname))
  213. local bug_url = (
  214. 'https://github.com/neovim/neovim/issues/new?labels=bug&title=user+docs+HTML%3A+'
  215. .. vim.fs.basename(fname)
  216. .. '+&body=%60gen_help_html.lua%60+problem+at%3A+'
  217. .. this_url
  218. .. '%0D'
  219. .. (token_name and '+unhandled+token%3A+%60' .. token_name .. '%60' or '')
  220. .. '%0DContext%3A%0D%0D%60%60%60%0D'
  221. .. url_encode(sample_text)
  222. .. '%0D%60%60%60'
  223. )
  224. return bug_url
  225. end
  226. --- Gets a "foo.html" name from a "foo.txt" helpfile name.
  227. local function get_helppage(f)
  228. if not f then
  229. return nil
  230. end
  231. -- Special case: help.txt is the "main landing page" of :help files, not index.txt.
  232. if f == 'index.txt' then
  233. return 'vimindex.html'
  234. elseif f == 'help.txt' then
  235. return 'index.html'
  236. end
  237. return (f:gsub('%.txt$', '.html'))
  238. end
  239. --- Counts leading spaces (tab=8) to decide the indent size of multiline text.
  240. ---
  241. --- Blank lines (empty or whitespace-only) are ignored.
  242. local function get_indent(s)
  243. local min_indent = nil
  244. for line in vim.gsplit(s, '\n') do
  245. if line and not is_blank(line) then
  246. local ws = expandtabs(line:match('^%s+') or '')
  247. min_indent = (not min_indent or ws:len() < min_indent) and ws:len() or min_indent
  248. end
  249. end
  250. return min_indent or 0
  251. end
  252. --- Removes the common indent level, after expanding tabs to 8 spaces.
  253. local function trim_indent(s)
  254. local indent_size = get_indent(s)
  255. local trimmed = ''
  256. for line in vim.gsplit(s, '\n') do
  257. line = expandtabs(line)
  258. trimmed = ('%s%s\n'):format(trimmed, line:sub(indent_size + 1))
  259. end
  260. return trimmed:sub(1, -2)
  261. end
  262. --- Gets raw buffer text in the node's range (+/- an offset), as a newline-delimited string.
  263. ---@param node TSNode
  264. ---@param bufnr integer
  265. ---@param offset integer
  266. local function getbuflinestr(node, bufnr, offset)
  267. local line1, _, line2, _ = node:range()
  268. line1 = line1 - offset
  269. line2 = line2 + offset
  270. local lines = vim.fn.getbufline(bufnr, line1 + 1, line2 + 1)
  271. return table.concat(lines, '\n')
  272. end
  273. --- Gets the whitespace just before `node` from the raw buffer text.
  274. --- Needed for preformatted `old` lines.
  275. ---@param node TSNode
  276. ---@param bufnr integer
  277. ---@return string
  278. local function getws(node, bufnr)
  279. local line1, c1, line2, _ = node:range()
  280. ---@type string
  281. local raw = vim.fn.getbufline(bufnr, line1 + 1, line2 + 1)[1]
  282. local text_before = raw:sub(1, c1)
  283. local leading_ws = text_before:match('%s+$') or ''
  284. return leading_ws
  285. end
  286. local function get_tagname(node, bufnr)
  287. local text = vim.treesitter.get_node_text(node, bufnr)
  288. local tag = (node:type() == 'optionlink' or node:parent():type() == 'optionlink')
  289. and ("'%s'"):format(text)
  290. or text
  291. local helpfile = vim.fs.basename(tagmap[tag]) or nil -- "api.txt"
  292. local helppage = get_helppage(helpfile) -- "api.html"
  293. return helppage, tag
  294. end
  295. --- Returns true if the given invalid tagname is a false positive.
  296. local function ignore_invalid(s)
  297. return not not (
  298. exclude_invalid[s]
  299. -- Strings like |~/====| appear in various places and the parser thinks they are links, but they
  300. -- are just table borders.
  301. or s:find('===')
  302. or s:find('%-%-%-')
  303. )
  304. end
  305. local function ignore_parse_error(fname, s)
  306. if ignore_errors[vim.fs.basename(fname)] then
  307. return true
  308. end
  309. -- Ignore parse errors for unclosed tag.
  310. -- This is common in vimdocs and is treated as plaintext by :help.
  311. return s:find("^[`'|*]")
  312. end
  313. ---@param node TSNode
  314. local function has_ancestor(node, ancestor_name)
  315. local p = node ---@type TSNode?
  316. while p do
  317. p = p:parent()
  318. if not p or p:type() == 'help_file' then
  319. break
  320. elseif p:type() == ancestor_name then
  321. return true
  322. end
  323. end
  324. return false
  325. end
  326. --- Gets the first matching child node matching `name`.
  327. ---@param node TSNode
  328. local function first(node, name)
  329. for c, _ in node:iter_children() do
  330. if c:named() and c:type() == name then
  331. return c
  332. end
  333. end
  334. return nil
  335. end
  336. local function validate_link(node, bufnr, fname)
  337. local helppage, tagname = get_tagname(node:child(1), bufnr)
  338. local ignored = false
  339. if not tagmap[tagname] then
  340. ignored = has_ancestor(node, 'column_heading') or node:has_error() or ignore_invalid(tagname)
  341. if not ignored then
  342. invalid_links[tagname] = vim.fs.basename(fname)
  343. end
  344. end
  345. return helppage, tagname, ignored
  346. end
  347. --- TODO: port the logic from scripts/check_urls.vim
  348. local function validate_url(text, fname)
  349. local ignored = false
  350. if ignore_errors[vim.fs.basename(fname)] then
  351. ignored = true
  352. elseif text:find('http%:') and not exclude_invalid_urls[text] then
  353. invalid_urls[text] = vim.fs.basename(fname)
  354. end
  355. return ignored
  356. end
  357. --- Traverses the tree at `root` and checks that |tag| links point to valid helptags.
  358. ---@param root TSNode
  359. ---@param level integer
  360. ---@param lang_tree TSTree
  361. ---@param opt table
  362. ---@param stats table
  363. local function visit_validate(root, level, lang_tree, opt, stats)
  364. level = level or 0
  365. local node_name = (root.named and root:named()) and root:type() or nil
  366. -- Parent kind (string).
  367. local parent = root:parent() and root:parent():type() or nil
  368. local toplevel = level < 1
  369. local function node_text(node)
  370. return vim.treesitter.get_node_text(node or root, opt.buf)
  371. end
  372. local text = trim(node_text())
  373. if root:child_count() > 0 then
  374. for node, _ in root:iter_children() do
  375. if node:named() then
  376. visit_validate(node, level + 1, lang_tree, opt, stats)
  377. end
  378. end
  379. end
  380. if node_name == 'ERROR' then
  381. if ignore_parse_error(opt.fname, text) then
  382. return
  383. end
  384. -- Store the raw text to give context to the error report.
  385. local sample_text = not toplevel and getbuflinestr(root, opt.buf, 0) or '[top level!]'
  386. -- Flatten the sample text to a single, truncated line.
  387. sample_text = vim.trim(sample_text):gsub('[\t\n]', ' '):sub(1, 80)
  388. table.insert(stats.parse_errors, sample_text)
  389. elseif
  390. (node_name == 'word' or node_name == 'uppercase_name')
  391. and (not vim.tbl_contains({ 'codespan', 'taglink', 'tag' }, parent))
  392. then
  393. local text_nopunct = vim.fn.trim(text, '.,', 0) -- Ignore some punctuation.
  394. local fname_basename = assert(vim.fs.basename(opt.fname))
  395. if spell_dict[text_nopunct] then
  396. local should_ignore = (
  397. spell_ignore_files[fname_basename] == true
  398. or vim.tbl_contains(
  399. (spell_ignore_files[fname_basename] or {}) --[[ @as string[] ]],
  400. text_nopunct
  401. )
  402. )
  403. if not should_ignore then
  404. invalid_spelling[text_nopunct] = invalid_spelling[text_nopunct] or {}
  405. invalid_spelling[text_nopunct][fname_basename] = node_text(root:parent())
  406. end
  407. end
  408. elseif node_name == 'url' then
  409. local fixed_url, _ = fix_url(trim(text))
  410. validate_url(fixed_url, opt.fname)
  411. elseif node_name == 'taglink' or node_name == 'optionlink' then
  412. local _, _, _ = validate_link(root, opt.buf, opt.fname)
  413. end
  414. end
  415. -- Fix tab alignment issues caused by concealed characters like |, `, * in tags
  416. -- and code blocks.
  417. ---@param text string
  418. ---@param next_node_text string
  419. local function fix_tab_after_conceal(text, next_node_text)
  420. -- Vim tabs take into account the two concealed characters even though they
  421. -- are invisible, so we need to add back in the two spaces if this is
  422. -- followed by a tab to make the tab alignment to match Vim's behavior.
  423. if string.sub(next_node_text, 1, 1) == '\t' then
  424. text = text .. ' '
  425. end
  426. return text
  427. end
  428. ---@class (exact) nvim.gen_help_html.heading
  429. ---@field name string
  430. ---@field subheadings nvim.gen_help_html.heading[]
  431. ---@field tag string
  432. -- Generates HTML from node `root` recursively.
  433. ---@param root TSNode
  434. ---@param level integer
  435. ---@param lang_tree TSTree
  436. ---@param headings nvim.gen_help_html.heading[]
  437. ---@param opt table
  438. ---@param stats table
  439. local function visit_node(root, level, lang_tree, headings, opt, stats)
  440. level = level or 0
  441. local node_name = (root.named and root:named()) and root:type() or nil
  442. -- Previous sibling kind (string).
  443. local prev = root:prev_sibling()
  444. and (root:prev_sibling().named and root:prev_sibling():named())
  445. and root:prev_sibling():type()
  446. or nil
  447. -- Next sibling kind (string).
  448. local next_ = root:next_sibling()
  449. and (root:next_sibling().named and root:next_sibling():named())
  450. and root:next_sibling():type()
  451. or nil
  452. -- Parent kind (string).
  453. local parent = root:parent() and root:parent():type() or nil
  454. local text = ''
  455. -- Gets leading whitespace of `node`.
  456. local function ws(node)
  457. node = node or root
  458. local ws_ = getws(node, opt.buf)
  459. -- XXX: first node of a (line) includes whitespace, even after
  460. -- https://github.com/neovim/tree-sitter-vimdoc/pull/31 ?
  461. if ws_ == '' then
  462. ws_ = vim.treesitter.get_node_text(node, opt.buf):match('^%s+') or ''
  463. end
  464. return ws_
  465. end
  466. local function node_text(node, ws_)
  467. node = node or root
  468. ws_ = (ws_ == nil or ws_ == true) and getws(node, opt.buf) or ''
  469. return string.format('%s%s', ws_, vim.treesitter.get_node_text(node, opt.buf))
  470. end
  471. local trimmed ---@type string
  472. if root:named_child_count() == 0 or node_name == 'ERROR' then
  473. text = node_text()
  474. trimmed = html_esc(trim(text))
  475. text = html_esc(text)
  476. else
  477. -- Process children and join them with whitespace.
  478. for node, _ in root:iter_children() do
  479. if node:named() then
  480. local r = visit_node(node, level + 1, lang_tree, headings, opt, stats)
  481. text = string.format('%s%s', text, r)
  482. end
  483. end
  484. trimmed = trim(text)
  485. end
  486. if node_name == 'help_file' then -- root node
  487. return text
  488. elseif node_name == 'url' then
  489. local fixed_url, removed_chars = fix_url(trimmed)
  490. return ('%s<a href="%s">%s</a>%s'):format(ws(), fixed_url, fixed_url, removed_chars)
  491. elseif node_name == 'word' or node_name == 'uppercase_name' then
  492. return text
  493. elseif node_name == 'h1' or node_name == 'h2' or node_name == 'h3' then
  494. if is_noise(text, stats.noise_lines) then
  495. return '' -- Discard common "noise" lines.
  496. end
  497. -- Remove "===" and tags from ToC text.
  498. local hname = (node_text():gsub('%-%-%-%-+', ''):gsub('%=%=%=%=+', ''):gsub('%*.*%*', ''))
  499. -- Use the first *tag* node as the heading anchor, if any.
  500. local tagnode = first(root, 'tag')
  501. -- Use the *tag* as the heading anchor id, if possible.
  502. local tagname = tagnode and url_encode(node_text(tagnode:child(1), false))
  503. or to_heading_tag(hname)
  504. if node_name == 'h1' or #headings == 0 then
  505. ---@type nvim.gen_help_html.heading
  506. local heading = { name = hname, subheadings = {}, tag = tagname }
  507. headings[#headings + 1] = heading
  508. else
  509. table.insert(
  510. headings[#headings].subheadings,
  511. { name = hname, subheadings = {}, tag = tagname }
  512. )
  513. end
  514. local el = node_name == 'h1' and 'h2' or 'h3'
  515. return ('<%s id="%s" class="help-heading">%s</%s>\n'):format(el, tagname, text, el)
  516. elseif node_name == 'column_heading' or node_name == 'column_name' then
  517. if root:has_error() then
  518. return text
  519. end
  520. return ('<div class="help-column_heading">%s</div>'):format(text)
  521. elseif node_name == 'block' then
  522. if is_blank(text) then
  523. return ''
  524. end
  525. if opt.old then
  526. -- XXX: Treat "old" docs as preformatted: they use indentation for layout.
  527. -- Trim trailing newlines to avoid too much whitespace between divs.
  528. return ('<div class="old-help-para">%s</div>\n'):format(trim(text, 2))
  529. end
  530. return string.format('<div class="help-para">\n%s\n</div>\n', text)
  531. elseif node_name == 'line' then
  532. if
  533. (parent ~= 'codeblock' or parent ~= 'code')
  534. and (is_blank(text) or is_noise(text, stats.noise_lines))
  535. then
  536. return '' -- Discard common "noise" lines.
  537. end
  538. -- XXX: Avoid newlines (too much whitespace) after block elements in old (preformatted) layout.
  539. local div = opt.old
  540. and root:child(0)
  541. and vim.list_contains({ 'column_heading', 'h1', 'h2', 'h3' }, root:child(0):type())
  542. return string.format('%s%s', div and trim(text) or text, div and '' or '\n')
  543. elseif node_name == 'line_li' then
  544. local sib = root:prev_sibling()
  545. local prev_li = sib and sib:type() == 'line_li'
  546. if not prev_li then
  547. opt.indent = 1
  548. else
  549. -- The previous listitem _sibling_ is _logically_ the _parent_ if it is indented less.
  550. local parent_indent = get_indent(node_text(sib))
  551. local this_indent = get_indent(node_text())
  552. if this_indent > parent_indent then
  553. opt.indent = opt.indent + 1
  554. elseif this_indent < parent_indent then
  555. opt.indent = math.max(1, opt.indent - 1)
  556. end
  557. end
  558. local margin = opt.indent == 1 and '' or ('margin-left: %drem;'):format((1.5 * opt.indent))
  559. return string.format('<div class="help-li" style="%s">%s</div>', margin, text)
  560. elseif node_name == 'taglink' or node_name == 'optionlink' then
  561. local helppage, tagname, ignored = validate_link(root, opt.buf, opt.fname)
  562. if ignored then
  563. return text
  564. end
  565. local s = ('%s<a href="%s#%s">%s</a>'):format(
  566. ws(),
  567. helppage,
  568. url_encode(tagname),
  569. html_esc(tagname)
  570. )
  571. if opt.old and node_name == 'taglink' then
  572. s = fix_tab_after_conceal(s, node_text(root:next_sibling()))
  573. end
  574. return s
  575. elseif vim.list_contains({ 'codespan', 'keycode' }, node_name) then
  576. if root:has_error() then
  577. return text
  578. end
  579. local s = ('%s<code>%s</code>'):format(ws(), trimmed)
  580. if opt.old and node_name == 'codespan' then
  581. s = fix_tab_after_conceal(s, node_text(root:next_sibling()))
  582. end
  583. return s
  584. elseif node_name == 'argument' then
  585. return ('%s<code>{%s}</code>'):format(ws(), text)
  586. elseif node_name == 'codeblock' then
  587. return text
  588. elseif node_name == 'language' then
  589. language = node_text(root)
  590. return ''
  591. elseif node_name == 'code' then -- Highlighted codeblock (child).
  592. if is_blank(text) then
  593. return ''
  594. end
  595. local code ---@type string
  596. if language then
  597. code = ('<pre><code class="language-%s">%s</code></pre>'):format(
  598. language,
  599. trim(trim_indent(text), 2)
  600. )
  601. language = nil
  602. else
  603. code = ('<pre>%s</pre>'):format(trim(trim_indent(text), 2))
  604. end
  605. return code
  606. elseif node_name == 'tag' then -- anchor
  607. if root:has_error() then
  608. return text
  609. end
  610. local in_heading = vim.list_contains({ 'h1', 'h2', 'h3' }, parent)
  611. local cssclass = (not in_heading and get_indent(node_text()) > 8) and 'help-tag-right'
  612. or 'help-tag'
  613. local tagname = node_text(root:child(1), false)
  614. if vim.tbl_count(stats.first_tags) < 2 then
  615. -- Force the first 2 tags in the doc to be anchored at the main heading.
  616. table.insert(stats.first_tags, tagname)
  617. return ''
  618. end
  619. local el = in_heading and 'span' or 'code'
  620. local encoded_tagname = url_encode(tagname)
  621. local s = ('%s<%s id="%s" class="%s"><a href="#%s">%s</a></%s>'):format(
  622. ws(),
  623. el,
  624. encoded_tagname,
  625. cssclass,
  626. encoded_tagname,
  627. trimmed,
  628. el
  629. )
  630. if opt.old then
  631. s = fix_tab_after_conceal(s, node_text(root:next_sibling()))
  632. end
  633. if in_heading and prev ~= 'tag' then
  634. -- Don't set "id", let the heading use the tag as its "id" (used by search engines).
  635. s = ('%s<%s class="%s"><a href="#%s">%s</a></%s>'):format(
  636. ws(),
  637. el,
  638. cssclass,
  639. encoded_tagname,
  640. trimmed,
  641. el
  642. )
  643. -- Start the <span> container for tags in a heading.
  644. -- This makes "justify-content:space-between" right-align the tags.
  645. -- <h2>foo bar<span>tag1 tag2</span></h2>
  646. return string.format('<span class="help-heading-tags">%s', s)
  647. elseif in_heading and next_ == nil then
  648. -- End the <span> container for tags in a heading.
  649. return string.format('%s</span>', s)
  650. end
  651. return s
  652. elseif node_name == 'ERROR' then
  653. if ignore_parse_error(opt.fname, trimmed) then
  654. return text
  655. end
  656. -- Store the raw text to give context to the bug report.
  657. local sample_text = level > 0 and getbuflinestr(root, opt.buf, 3) or '[top level!]'
  658. table.insert(stats.parse_errors, sample_text)
  659. return ('<a class="parse-error" target="_blank" title="Report bug... (parse error)" href="%s">%s</a>'):format(
  660. get_bug_url_vimdoc(opt.fname, opt.to_fname, sample_text),
  661. trimmed
  662. )
  663. else -- Unknown token.
  664. local sample_text = level > 0 and getbuflinestr(root, opt.buf, 3) or '[top level!]'
  665. return ('<a class="unknown-token" target="_blank" title="Report bug... (unhandled token "%s")" href="%s">%s</a>'):format(
  666. node_name,
  667. get_bug_url_nvim(opt.fname, opt.to_fname, sample_text, node_name),
  668. trimmed
  669. ),
  670. ('unknown-token:"%s"'):format(node_name)
  671. end
  672. end
  673. --- @param dir string e.g. '$VIMRUNTIME/doc'
  674. --- @param include string[]|nil
  675. --- @return string[]
  676. local function get_helpfiles(dir, include)
  677. local rv = {}
  678. for f, type in vim.fs.dir(dir) do
  679. if
  680. vim.endswith(f, '.txt')
  681. and type == 'file'
  682. and (not include or vim.list_contains(include, f))
  683. then
  684. local fullpath = vim.fn.fnamemodify(('%s/%s'):format(dir, f), ':p')
  685. table.insert(rv, fullpath)
  686. end
  687. end
  688. return rv
  689. end
  690. --- Populates the helptags map.
  691. local function get_helptags(help_dir)
  692. local m = {}
  693. -- Load a random help file to convince taglist() to do its job.
  694. vim.cmd(string.format('split %s/api.txt', help_dir))
  695. vim.cmd('lcd %:p:h')
  696. for _, item in ipairs(vim.fn.taglist('.*')) do
  697. if vim.endswith(item.filename, '.txt') then
  698. m[item.name] = item.filename
  699. end
  700. end
  701. vim.cmd('q!')
  702. return m
  703. end
  704. --- Use the vimdoc parser defined in the build, not whatever happens to be installed on the system.
  705. local function ensure_runtimepath()
  706. if not vim.o.runtimepath:find('build/lib/nvim/') then
  707. vim.cmd [[set runtimepath^=./build/lib/nvim/]]
  708. end
  709. end
  710. --- Opens `fname` in a buffer and gets a treesitter parser for the buffer contents.
  711. ---
  712. --- @param fname string help file to parse
  713. --- @param parser_path string? path to non-default vimdoc.so
  714. --- @return vim.treesitter.LanguageTree, integer (lang_tree, bufnr)
  715. local function parse_buf(fname, parser_path)
  716. local buf ---@type integer
  717. if type(fname) == 'string' then
  718. vim.cmd('split ' .. vim.fn.fnameescape(fname)) -- Filename.
  719. buf = vim.api.nvim_get_current_buf()
  720. else
  721. -- Left for debugging
  722. ---@diagnostic disable-next-line: no-unknown
  723. buf = fname
  724. vim.cmd('sbuffer ' .. tostring(fname)) -- Buffer number.
  725. end
  726. if parser_path then
  727. vim.treesitter.language.add('vimdoc', { path = parser_path })
  728. end
  729. local lang_tree = vim.treesitter.get_parser(buf)
  730. return lang_tree, buf
  731. end
  732. --- Validates one :help file `fname`:
  733. --- - checks that |tag| links point to valid helptags.
  734. --- - recursively counts parse errors ("ERROR" nodes)
  735. ---
  736. --- @param fname string help file to validate
  737. --- @param parser_path string? path to non-default vimdoc.so
  738. --- @return { invalid_links: number, parse_errors: string[] }
  739. local function validate_one(fname, parser_path)
  740. local stats = {
  741. parse_errors = {},
  742. }
  743. local lang_tree, buf = parse_buf(fname, parser_path)
  744. for _, tree in ipairs(lang_tree:trees()) do
  745. visit_validate(tree:root(), 0, tree, { buf = buf, fname = fname }, stats)
  746. end
  747. lang_tree:destroy()
  748. vim.cmd.close()
  749. return stats
  750. end
  751. --- Generates HTML from one :help file `fname` and writes the result to `to_fname`.
  752. ---
  753. --- @param fname string Source :help file
  754. --- @param to_fname string Destination .html file
  755. --- @param old boolean Preformat paragraphs (for old :help files which are full of arbitrary whitespace)
  756. --- @param parser_path string? path to non-default vimdoc.so
  757. ---
  758. --- @return string html
  759. --- @return table stats
  760. local function gen_one(fname, to_fname, old, commit, parser_path)
  761. local stats = {
  762. noise_lines = {},
  763. parse_errors = {},
  764. first_tags = {}, -- Track the first few tags in doc.
  765. }
  766. local lang_tree, buf = parse_buf(fname, parser_path)
  767. ---@type nvim.gen_help_html.heading[]
  768. local headings = {} -- Headings (for ToC). 2-dimensional: h1 contains h2/h3.
  769. local title = to_titlecase(basename_noext(fname))
  770. local html = ([[
  771. <!DOCTYPE html>
  772. <html>
  773. <head>
  774. <meta charset="utf-8">
  775. <meta http-equiv="X-UA-Compatible" content="IE=edge">
  776. <meta name="viewport" content="width=device-width, initial-scale=1">
  777. <meta name="description" content="Neovim user documentation">
  778. <!-- algolia docsearch https://docsearch.algolia.com/docs/docsearch-v3/ -->
  779. <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@docsearch/css@3" />
  780. <link rel="preconnect" href="https://X185E15FPG-dsn.algolia.net" crossorigin />
  781. <link href="/css/normalize.min.css" rel="stylesheet">
  782. <link href="/css/bootstrap.css" rel="stylesheet">
  783. <link href="/css/main.css" rel="stylesheet">
  784. <link href="help.css" rel="stylesheet">
  785. <link href="/highlight/styles/neovim.min.css" rel="stylesheet">
  786. <script src="/highlight/highlight.min.js"></script>
  787. <script>hljs.highlightAll();</script>
  788. <title>%s - Neovim docs</title>
  789. </head>
  790. <body>
  791. ]]):format(title)
  792. local logo_svg = [[
  793. <svg xmlns="http://www.w3.org/2000/svg" role="img" width="173" height="50" viewBox="0 0 742 214" aria-label="Neovim">
  794. <title>Neovim</title>
  795. <defs>
  796. <linearGradient x1="50%" y1="0%" x2="50%" y2="100%" id="a">
  797. <stop stop-color="#16B0ED" stop-opacity=".8" offset="0%" />
  798. <stop stop-color="#0F59B2" stop-opacity=".837" offset="100%" />
  799. </linearGradient>
  800. <linearGradient x1="50%" y1="0%" x2="50%" y2="100%" id="b">
  801. <stop stop-color="#7DB643" offset="0%" />
  802. <stop stop-color="#367533" offset="100%" />
  803. </linearGradient>
  804. <linearGradient x1="50%" y1="0%" x2="50%" y2="100%" id="c">
  805. <stop stop-color="#88C649" stop-opacity=".8" offset="0%" />
  806. <stop stop-color="#439240" stop-opacity=".84" offset="100%" />
  807. </linearGradient>
  808. </defs>
  809. <g fill="none" fill-rule="evenodd">
  810. <path
  811. d="M.027 45.459L45.224-.173v212.171L.027 166.894V45.459z"
  812. fill="url(#a)"
  813. transform="translate(1 1)"
  814. />
  815. <path
  816. d="M129.337 45.89L175.152-.149l-.928 212.146-45.197-45.104.31-121.005z"
  817. fill="url(#b)"
  818. transform="matrix(-1 0 0 1 305 1)"
  819. />
  820. <path
  821. d="M45.194-.137L162.7 179.173l-32.882 32.881L12.25 33.141 45.194-.137z"
  822. fill="url(#c)"
  823. transform="translate(1 1)"
  824. />
  825. <path
  826. d="M46.234 84.032l-.063 7.063-36.28-53.563 3.36-3.422 32.983 49.922z"
  827. fill-opacity=".13"
  828. fill="#000"
  829. />
  830. <g fill="#444">
  831. <path
  832. 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"
  833. fill="currentColor"
  834. />
  835. <path
  836. 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"
  837. fill="currentColor"
  838. fill-opacity=".8"
  839. />
  840. </g>
  841. </g>
  842. </svg>
  843. ]]
  844. local main = ''
  845. for _, tree in ipairs(lang_tree:trees()) do
  846. main = main
  847. .. (
  848. visit_node(
  849. tree:root(),
  850. 0,
  851. tree,
  852. headings,
  853. { buf = buf, old = old, fname = fname, to_fname = to_fname, indent = 1 },
  854. stats
  855. )
  856. )
  857. end
  858. main = ([[
  859. <header class="container">
  860. <nav class="navbar navbar-expand-lg">
  861. <div class="container-fluid">
  862. <a href="/" class="navbar-brand" aria-label="logo">
  863. <!--TODO: use <img src="….svg"> here instead. Need one that has green lettering instead of gray. -->
  864. %s
  865. <!--<img src="https://neovim.io/logos/neovim-logo.svg" width="173" height="50" alt="Neovim" />-->
  866. </a>
  867. <div id="docsearch"></div> <!-- algolia docsearch https://docsearch.algolia.com/docs/docsearch-v3/ -->
  868. </div>
  869. </nav>
  870. </header>
  871. <div class="container golden-grid help-body">
  872. <div class="col-wide">
  873. <a name="%s"></a><h1 id="%s">%s</h1>
  874. <p>
  875. <i>
  876. Nvim <code>:help</code> pages, <a href="https://github.com/neovim/neovim/blob/master/scripts/gen_help_html.lua">generated</a>
  877. from <a href="https://github.com/neovim/neovim/blob/master/runtime/doc/%s">source</a>
  878. using the <a href="https://github.com/neovim/tree-sitter-vimdoc">tree-sitter-vimdoc</a> parser.
  879. </i>
  880. </p>
  881. <hr/>
  882. %s
  883. </div>
  884. ]]):format(
  885. logo_svg,
  886. stats.first_tags[2] or '',
  887. stats.first_tags[1] or '',
  888. title,
  889. vim.fs.basename(fname),
  890. main
  891. )
  892. ---@type string
  893. local toc = [[
  894. <div class="col-narrow toc">
  895. <div><a href="index.html">Main</a></div>
  896. <div><a href="vimindex.html">Commands index</a></div>
  897. <div><a href="quickref.html">Quick reference</a></div>
  898. <hr/>
  899. ]]
  900. local n = 0 -- Count of all headings + subheadings.
  901. for _, h1 in ipairs(headings) do
  902. n = n + 1 + #h1.subheadings
  903. end
  904. for _, h1 in ipairs(headings) do
  905. ---@type string
  906. toc = toc .. ('<div class="help-toc-h1"><a href="#%s">%s</a>\n'):format(h1.tag, h1.name)
  907. if n < 30 or #headings < 10 then -- Show subheadings only if there aren't too many.
  908. for _, h2 in ipairs(h1.subheadings) do
  909. toc = toc
  910. .. ('<div class="help-toc-h2"><a href="#%s">%s</a></div>\n'):format(h2.tag, h2.name)
  911. end
  912. end
  913. toc = toc .. '</div>'
  914. end
  915. toc = toc .. '</div>\n'
  916. local bug_url = get_bug_url_nvim(fname, to_fname, 'TODO', nil)
  917. local bug_link = string.format('(<a href="%s" target="_blank">report docs bug...</a>)', bug_url)
  918. local footer = ([[
  919. <footer>
  920. <div class="container flex">
  921. <div class="generator-stats">
  922. Generated at %s from <code><a href="https://github.com/neovim/neovim/commit/%s">%s</a></code>
  923. </div>
  924. <div class="generator-stats">
  925. parse_errors: %d %s | <span title="%s">noise_lines: %d</span>
  926. </div>
  927. <div>
  928. <!-- algolia docsearch https://docsearch.algolia.com/docs/docsearch-v3/ -->
  929. <script src="https://cdn.jsdelivr.net/npm/@docsearch/js@3"></script>
  930. <script type="module">
  931. docsearch({
  932. container: '#docsearch',
  933. appId: 'X185E15FPG',
  934. apiKey: 'b5e6b2f9c636b2b471303205e59832ed',
  935. indexName: 'nvim',
  936. });
  937. </script>
  938. </footer>
  939. ]]):format(
  940. os.date('%Y-%m-%d %H:%M'),
  941. commit,
  942. commit:sub(1, 7),
  943. #stats.parse_errors,
  944. bug_link,
  945. html_esc(table.concat(stats.noise_lines, '\n')),
  946. #stats.noise_lines
  947. )
  948. html = ('%s%s%s</div>\n%s</body>\n</html>\n'):format(html, main, toc, footer)
  949. vim.cmd('q!')
  950. lang_tree:destroy()
  951. return html, stats
  952. end
  953. local function gen_css(fname)
  954. local css = [[
  955. :root {
  956. --code-color: #004b4b;
  957. --tag-color: #095943;
  958. }
  959. @media (prefers-color-scheme: dark) {
  960. :root {
  961. --code-color: #00c243;
  962. --tag-color: #00b7b7;
  963. }
  964. }
  965. @media (min-width: 40em) {
  966. .toc {
  967. position: fixed;
  968. left: 67%;
  969. }
  970. .golden-grid {
  971. display: grid;
  972. grid-template-columns: 65% auto;
  973. grid-gap: 1em;
  974. }
  975. }
  976. @media (max-width: 40em) {
  977. .golden-grid {
  978. /* Disable grid for narrow viewport (mobile phone). */
  979. display: block;
  980. }
  981. }
  982. .toc {
  983. /* max-width: 12rem; */
  984. height: 85%; /* Scroll if there are too many items. https://github.com/neovim/neovim.github.io/issues/297 */
  985. overflow: auto; /* Scroll if there are too many items. https://github.com/neovim/neovim.github.io/issues/297 */
  986. }
  987. .toc > div {
  988. text-overflow: ellipsis;
  989. overflow: hidden;
  990. white-space: nowrap;
  991. }
  992. html {
  993. scroll-behavior: auto;
  994. }
  995. body {
  996. font-size: 18px;
  997. line-height: 1.5;
  998. }
  999. h1, h2, h3, h4, h5 {
  1000. font-family: sans-serif;
  1001. border-bottom: 1px solid var(--tag-color); /*rgba(0, 0, 0, .9);*/
  1002. }
  1003. h3, h4, h5 {
  1004. border-bottom-style: dashed;
  1005. }
  1006. .help-column_heading {
  1007. color: var(--code-color);
  1008. }
  1009. .help-body {
  1010. padding-bottom: 2em;
  1011. }
  1012. .help-line {
  1013. /* font-family: ui-monospace,SFMono-Regular,SF Mono,Menlo,Consolas,Liberation Mono,monospace; */
  1014. }
  1015. .help-li {
  1016. white-space: normal;
  1017. display: list-item;
  1018. margin-left: 1.5rem; /* padding-left: 1rem; */
  1019. }
  1020. .help-para {
  1021. padding-top: 10px;
  1022. padding-bottom: 10px;
  1023. }
  1024. .old-help-para {
  1025. padding-top: 10px;
  1026. padding-bottom: 10px;
  1027. /* Tabs are used for alignment in old docs, so we must match Vim's 8-char expectation. */
  1028. tab-size: 8;
  1029. white-space: pre-wrap;
  1030. font-size: 16px;
  1031. font-family: ui-monospace,SFMono-Regular,SF Mono,Menlo,Consolas,Liberation Mono,monospace;
  1032. word-wrap: break-word;
  1033. }
  1034. .old-help-para pre, .old-help-para pre:hover {
  1035. /* Text following <pre> is already visually separated by the linebreak. */
  1036. margin-bottom: 0;
  1037. /* Long lines that exceed the textwidth should not be wrapped (no "pre-wrap").
  1038. Since text may overflow horizontally, we make the contents to be scrollable
  1039. (only if necessary) to prevent overlapping with the navigation bar at the right. */
  1040. white-space: pre;
  1041. overflow-x: auto;
  1042. }
  1043. /* TODO: should this rule be deleted? help tags are rendered as <code> or <span>, not <a> */
  1044. a.help-tag, a.help-tag:focus, a.help-tag:hover {
  1045. color: inherit;
  1046. text-decoration: none;
  1047. }
  1048. .help-tag {
  1049. color: var(--tag-color);
  1050. }
  1051. /* Tag pseudo-header common in :help docs. */
  1052. .help-tag-right {
  1053. color: var(--tag-color);
  1054. margin-left: auto;
  1055. margin-right: 0;
  1056. float: right;
  1057. }
  1058. .help-tag a,
  1059. .help-tag-right a {
  1060. color: inherit;
  1061. }
  1062. .help-tag a:not(:hover),
  1063. .help-tag-right a:not(:hover) {
  1064. text-decoration: none;
  1065. }
  1066. h1 .help-tag, h2 .help-tag, h3 .help-tag {
  1067. font-size: smaller;
  1068. }
  1069. .help-heading {
  1070. overflow: hidden;
  1071. white-space: nowrap;
  1072. display: flex;
  1073. justify-content: space-between;
  1074. }
  1075. /* The (right-aligned) "tags" part of a section heading. */
  1076. .help-heading-tags {
  1077. margin-right: 10px;
  1078. }
  1079. .help-toc-h1 {
  1080. }
  1081. .help-toc-h2 {
  1082. margin-left: 1em;
  1083. }
  1084. .parse-error {
  1085. background-color: red;
  1086. }
  1087. .unknown-token {
  1088. color: black;
  1089. background-color: yellow;
  1090. }
  1091. code {
  1092. color: var(--code-color);
  1093. font-size: 16px;
  1094. }
  1095. pre {
  1096. /* Tabs are used in codeblocks only for indentation, not alignment, so we can aggressively shrink them. */
  1097. tab-size: 2;
  1098. white-space: pre-wrap;
  1099. line-height: 1.3; /* Important for ascii art. */
  1100. overflow: visible;
  1101. /* font-family: ui-monospace,SFMono-Regular,SF Mono,Menlo,Consolas,Liberation Mono,monospace; */
  1102. font-size: 16px;
  1103. margin-top: 10px;
  1104. }
  1105. pre:last-child {
  1106. margin-bottom: 0;
  1107. }
  1108. pre:hover,
  1109. .help-heading:hover {
  1110. overflow: visible;
  1111. }
  1112. .generator-stats {
  1113. color: gray;
  1114. font-size: smaller;
  1115. }
  1116. ]]
  1117. tofile(fname, css)
  1118. end
  1119. -- Testing
  1120. local function ok(cond, expected, actual, message)
  1121. assert(
  1122. (not expected and not actual) or (expected and actual),
  1123. 'if "expected" is given, "actual" is also required'
  1124. )
  1125. if expected then
  1126. assert(
  1127. cond,
  1128. ('%sexpected %s, got: %s'):format(
  1129. message and (message .. '\n') or '',
  1130. vim.inspect(expected),
  1131. vim.inspect(actual)
  1132. )
  1133. )
  1134. return cond
  1135. else
  1136. return assert(cond)
  1137. end
  1138. end
  1139. local function eq(expected, actual, message)
  1140. return ok(vim.deep_equal(expected, actual), expected, actual, message)
  1141. end
  1142. function M._test()
  1143. tagmap = get_helptags('$VIMRUNTIME/doc')
  1144. helpfiles = get_helpfiles(vim.fn.expand('$VIMRUNTIME/doc'))
  1145. ok(vim.tbl_count(tagmap) > 3000, '>3000', vim.tbl_count(tagmap))
  1146. ok(
  1147. vim.endswith(tagmap['vim.diagnostic.set()'], 'diagnostic.txt'),
  1148. tagmap['vim.diagnostic.set()'],
  1149. 'diagnostic.txt'
  1150. )
  1151. ok(vim.endswith(tagmap['%:s'], 'cmdline.txt'), tagmap['%:s'], 'cmdline.txt')
  1152. ok(is_noise([[vim:tw=78:isk=!-~,^*,^\|,^\":ts=8:noet:ft=help:norl:]]))
  1153. ok(is_noise([[ NVIM REFERENCE MANUAL by Thiago de Arruda ]]))
  1154. ok(not is_noise([[vim:tw=78]]))
  1155. eq(0, get_indent('a'))
  1156. eq(1, get_indent(' a'))
  1157. eq(2, get_indent(' a\n b\n c\n'))
  1158. eq(5, get_indent(' a\n \n b\n c\n d\n e\n'))
  1159. eq(
  1160. 'a\n \n b\n c\n d\n e\n',
  1161. trim_indent(' a\n \n b\n c\n d\n e\n')
  1162. )
  1163. local fixed_url, removed_chars = fix_url('https://example.com).')
  1164. eq('https://example.com', fixed_url)
  1165. eq(').', removed_chars)
  1166. fixed_url, removed_chars = fix_url('https://example.com.)')
  1167. eq('https://example.com.', fixed_url)
  1168. eq(')', removed_chars)
  1169. fixed_url, removed_chars = fix_url('https://example.com.')
  1170. eq('https://example.com', fixed_url)
  1171. eq('.', removed_chars)
  1172. fixed_url, removed_chars = fix_url('https://example.com)')
  1173. eq('https://example.com', fixed_url)
  1174. eq(')', removed_chars)
  1175. fixed_url, removed_chars = fix_url('https://example.com')
  1176. eq('https://example.com', fixed_url)
  1177. eq('', removed_chars)
  1178. print('all tests passed.\n')
  1179. end
  1180. --- @class nvim.gen_help_html.gen_result
  1181. --- @field helpfiles string[] list of generated HTML files, from the source docs {include}
  1182. --- @field err_count integer number of parse errors in :help docs
  1183. --- @field invalid_links table<string, any>
  1184. --- Generates HTML from :help docs located in `help_dir` and writes the result in `to_dir`.
  1185. ---
  1186. --- Example:
  1187. ---
  1188. --- gen('$VIMRUNTIME/doc', '/path/to/neovim.github.io/_site/doc/', {'api.txt', 'autocmd.txt', 'channel.txt'}, nil)
  1189. ---
  1190. --- @param help_dir string Source directory containing the :help files. Must run `make helptags` first.
  1191. --- @param to_dir string Target directory where the .html files will be written.
  1192. --- @param include string[]|nil Process only these filenames. Example: {'api.txt', 'autocmd.txt', 'channel.txt'}
  1193. ---
  1194. --- @return nvim.gen_help_html.gen_result result
  1195. function M.gen(help_dir, to_dir, include, commit, parser_path)
  1196. vim.validate {
  1197. help_dir = {
  1198. help_dir,
  1199. function(d)
  1200. return vim.fn.isdirectory(vim.fn.expand(d)) == 1
  1201. end,
  1202. 'valid directory',
  1203. },
  1204. to_dir = { to_dir, 's' },
  1205. include = { include, 't', true },
  1206. commit = { commit, 's', true },
  1207. parser_path = {
  1208. parser_path,
  1209. function(f)
  1210. return f == nil or vim.fn.filereadable(vim.fn.expand(f)) == 1
  1211. end,
  1212. 'valid vimdoc.{so,dll} filepath',
  1213. },
  1214. }
  1215. local err_count = 0
  1216. ensure_runtimepath()
  1217. tagmap = get_helptags(vim.fn.expand(help_dir))
  1218. helpfiles = get_helpfiles(help_dir, include)
  1219. to_dir = vim.fn.expand(to_dir)
  1220. parser_path = parser_path and vim.fn.expand(parser_path) or nil
  1221. print(('output dir: %s'):format(to_dir))
  1222. vim.fn.mkdir(to_dir, 'p')
  1223. gen_css(('%s/help.css'):format(to_dir))
  1224. for _, f in ipairs(helpfiles) do
  1225. local helpfile = vim.fs.basename(f)
  1226. local to_fname = ('%s/%s'):format(to_dir, get_helppage(helpfile))
  1227. local html, stats = gen_one(f, to_fname, not new_layout[helpfile], commit or '?', parser_path)
  1228. tofile(to_fname, html)
  1229. print(
  1230. ('generated (%-4s errors): %-15s => %s'):format(
  1231. #stats.parse_errors,
  1232. helpfile,
  1233. vim.fs.basename(to_fname)
  1234. )
  1235. )
  1236. err_count = err_count + #stats.parse_errors
  1237. end
  1238. print(('generated %d html pages'):format(#helpfiles))
  1239. print(('total errors: %d'):format(err_count))
  1240. print(('invalid tags:\n%s'):format(vim.inspect(invalid_links)))
  1241. --- @type nvim.gen_help_html.gen_result
  1242. return {
  1243. helpfiles = helpfiles,
  1244. err_count = err_count,
  1245. invalid_links = invalid_links,
  1246. }
  1247. end
  1248. --- @class nvim.gen_help_html.validate_result
  1249. --- @field helpfiles integer number of generated helpfiles
  1250. --- @field err_count integer number of parse errors
  1251. --- @field parse_errors table<string, string[]>
  1252. --- @field invalid_links table<string, any> invalid tags in :help docs
  1253. --- @field invalid_urls table<string, any> invalid URLs in :help docs
  1254. --- @field invalid_spelling table<string, table<string, string>> invalid spelling in :help docs
  1255. --- Validates all :help files found in `help_dir`:
  1256. --- - checks that |tag| links point to valid helptags.
  1257. --- - recursively counts parse errors ("ERROR" nodes)
  1258. ---
  1259. --- This is 10x faster than gen(), for use in CI.
  1260. ---
  1261. --- @return nvim.gen_help_html.validate_result result
  1262. function M.validate(help_dir, include, parser_path)
  1263. vim.validate {
  1264. help_dir = {
  1265. help_dir,
  1266. function(d)
  1267. return vim.fn.isdirectory(vim.fn.expand(d)) == 1
  1268. end,
  1269. 'valid directory',
  1270. },
  1271. include = { include, 't', true },
  1272. parser_path = {
  1273. parser_path,
  1274. function(f)
  1275. return f == nil or vim.fn.filereadable(vim.fn.expand(f)) == 1
  1276. end,
  1277. 'valid vimdoc.{so,dll} filepath',
  1278. },
  1279. }
  1280. local err_count = 0 ---@type integer
  1281. local files_to_errors = {} ---@type table<string, string[]>
  1282. ensure_runtimepath()
  1283. tagmap = get_helptags(vim.fn.expand(help_dir))
  1284. helpfiles = get_helpfiles(help_dir, include)
  1285. parser_path = parser_path and vim.fn.expand(parser_path) or nil
  1286. for _, f in ipairs(helpfiles) do
  1287. local helpfile = assert(vim.fs.basename(f))
  1288. local rv = validate_one(f, parser_path)
  1289. print(('validated (%-4s errors): %s'):format(#rv.parse_errors, helpfile))
  1290. if #rv.parse_errors > 0 then
  1291. files_to_errors[helpfile] = rv.parse_errors
  1292. vim.print(('%s'):format(vim.iter(rv.parse_errors):fold('', function(s, v)
  1293. return s .. '\n ' .. v
  1294. end)))
  1295. end
  1296. err_count = err_count + #rv.parse_errors
  1297. end
  1298. ---@type nvim.gen_help_html.validate_result
  1299. return {
  1300. helpfiles = #helpfiles,
  1301. err_count = err_count,
  1302. parse_errors = files_to_errors,
  1303. invalid_links = invalid_links,
  1304. invalid_urls = invalid_urls,
  1305. invalid_spelling = invalid_spelling,
  1306. }
  1307. end
  1308. --- Validates vimdoc files on $VIMRUNTIME. and print human-readable error messages if fails.
  1309. ---
  1310. --- If this fails, try these steps (in order):
  1311. --- 1. Fix/cleanup the :help docs.
  1312. --- 2. Fix the parser: https://github.com/neovim/tree-sitter-vimdoc
  1313. --- 3. File a parser bug, and adjust the tolerance of this test in the meantime.
  1314. ---
  1315. --- @param help_dir? string e.g. '$VIMRUNTIME/doc' or './runtime/doc'
  1316. function M.run_validate(help_dir)
  1317. help_dir = vim.fn.expand(help_dir or '$VIMRUNTIME/doc')
  1318. print('doc path = ' .. vim.uv.fs_realpath(help_dir))
  1319. local rv = M.validate(help_dir)
  1320. -- Check that we actually found helpfiles.
  1321. ok(rv.helpfiles > 100, '>100 :help files', rv.helpfiles)
  1322. eq({}, rv.parse_errors, 'no parse errors')
  1323. eq(0, rv.err_count, 'no parse errors')
  1324. eq({}, rv.invalid_links, 'invalid tags in :help docs')
  1325. eq({}, rv.invalid_urls, 'invalid URLs in :help docs')
  1326. eq(
  1327. {},
  1328. rv.invalid_spelling,
  1329. 'invalid spelling in :help docs (see spell_dict in scripts/gen_help_html.lua)'
  1330. )
  1331. end
  1332. --- Test-generates HTML from docs.
  1333. ---
  1334. --- 1. Test that gen_help_html.lua actually works.
  1335. --- 2. Test that parse errors did not increase wildly. Because we explicitly test only a few
  1336. --- :help files, we can be precise about the tolerances here.
  1337. --- @param help_dir? string e.g. '$VIMRUNTIME/doc' or './runtime/doc'
  1338. function M.test_gen(help_dir)
  1339. local tmpdir = assert(vim.fs.dirname(vim.fn.tempname()))
  1340. help_dir = vim.fn.expand(help_dir or '$VIMRUNTIME/doc')
  1341. print('doc path = ' .. vim.uv.fs_realpath(help_dir))
  1342. local rv = M.gen(
  1343. help_dir,
  1344. tmpdir,
  1345. -- Because gen() is slow (~30s), this test is limited to a few files.
  1346. { 'pi_health.txt', 'help.txt', 'index.txt', 'nvim.txt' }
  1347. )
  1348. eq(4, #rv.helpfiles)
  1349. eq(0, rv.err_count, 'parse errors in :help docs')
  1350. eq({}, rv.invalid_links, 'invalid tags in :help docs')
  1351. end
  1352. return M