├── .gitignore ├── pyproject.toml ├── requirements.txt ├── luadox ├── data │ ├── foot.tmpl.html │ ├── img │ │ ├── i-download.svg │ │ ├── i-left.svg │ │ ├── i-right.svg │ │ ├── i-bitbucket.svg │ │ ├── i-gitlab.svg │ │ └── i-github.svg │ ├── search.tmpl.html │ ├── head.tmpl.html │ ├── search.js │ ├── prism.css │ ├── js-search.min.js │ ├── prism.js │ └── luadox.css ├── __init__.py ├── version.py ├── log.py ├── render │ ├── __init__.py │ ├── base.py │ ├── yaml.py │ ├── json.py │ └── html.py ├── assets.py ├── prerender.py ├── utils.py ├── tags.py ├── main.py └── reference.py ├── setup.cfg ├── Dockerfile ├── Makefile ├── LICENSE.txt └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | ext 3 | build 4 | __pycache__ 5 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.pyright] 2 | exclude = [ 3 | "**/build", 4 | ] 5 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | commonmark>=0.9.1 2 | commonmarkextensions>=0.0.6 3 | pyyaml>=6.0.0 4 | -------------------------------------------------------------------------------- /luadox/data/foot.tmpl.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /luadox/data/img/i-download.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name=luadox 3 | version=attr: luadox.version.__version__ 4 | 5 | [options] 6 | install_requires = file: requirements.txt 7 | include_package_data = True 8 | packages = find: 9 | 10 | [options.entry_points] 11 | console_scripts = 12 | luadox = luadox.main:main 13 | 14 | [options.package_data] 15 | luadox = 16 | data/** 17 | -------------------------------------------------------------------------------- /luadox/data/img/i-left.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /luadox/data/img/i-right.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /luadox/data/search.tmpl.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 |
5 | 6 | 7 | -------------------------------------------------------------------------------- /luadox/data/head.tmpl.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {title} 7 | 8 | 9 | {head} 10 | 11 | 12 | -------------------------------------------------------------------------------- /luadox/data/img/i-bitbucket.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /luadox/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021-2023 Jason Tackaberry 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11-alpine AS build 2 | RUN mkdir -p /tmp/luadox 3 | COPY requirements.txt pyproject.toml setup.cfg /tmp/luadox 4 | COPY luadox /tmp/luadox/luadox 5 | RUN pip install --user /tmp/luadox && \ 6 | find /root/.local -type f -exec chmod a+r "{}" \; && \ 7 | find /root/.local -type d -exec chmod a+rx "{}" \; 8 | 9 | FROM python:3.11-alpine 10 | RUN apk --update upgrade && rm -rf /var/cache/apk/* 11 | COPY --from=build /root/.local /opt/luadox 12 | ENV PYTHONPATH /opt/luadox/lib/python3.11/site-packages/ 13 | ENV PATH /opt/luadox/bin:/usr/local/bin:/usr/local/sbin:/usr/sbin:/usr/bin:/sbin:/bin 14 | ENTRYPOINT ["/opt/luadox/bin/luadox"] 15 | CMD ["--help"] 16 | -------------------------------------------------------------------------------- /luadox/version.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021-2023 Jason Tackaberry 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | __version__ = "2.0.0dev0" 16 | -------------------------------------------------------------------------------- /luadox/log.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021-2023 Jason Tackaberry 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import logging 16 | 17 | log = logging.getLogger('luadox') 18 | logging.basicConfig(format='%(asctime)s [%(levelname)s] %(message)s', level=logging.DEBUG) 19 | -------------------------------------------------------------------------------- /luadox/data/img/i-gitlab.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /luadox/render/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021-2023 Jason Tackaberry 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | __all__ = ['RENDERERS'] 16 | 17 | from typing import Dict, Type 18 | 19 | from .base import Renderer 20 | from .html import HTMLRenderer 21 | from .json import JSONRenderer 22 | from .yaml import YAMLRenderer 23 | 24 | RENDERERS: Dict[str, Type[Renderer]] = { 25 | 'html': HTMLRenderer, 26 | 'json': JSONRenderer, 27 | 'yaml': YAMLRenderer, 28 | } 29 | -------------------------------------------------------------------------------- /luadox/data/img/i-github.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | VER := $(shell grep __ver luadox/version.py | cut -d\" -f2) 2 | # Name of the release archive without extension 3 | ARCHIVE := luadox-$(VER) 4 | 5 | build/luadox: build/pkg.zip 6 | echo '#!/usr/bin/env python3' > build/luadox 7 | cat build/pkg.zip >> build/luadox 8 | chmod 755 build/luadox 9 | 10 | build/pkg: luadox/* 11 | mkdir -p build/pkg/luadox 12 | cp -a luadox/* build/pkg/luadox 13 | echo "from luadox.main import main; main()" > build/pkg/__main__.py 14 | touch build/pkg 15 | 16 | build/pkg/ext: requirements.txt 17 | @echo "*** installing dependencies into $@/" 18 | pip3 install -t ./build/pkg/ext -r requirements.txt 19 | @# These aren't needed 20 | rm -rf build/pkg/ext/bin build/pkg/ext/*.dist-info build/pkg/ext/*/*.so 21 | 22 | build/pkg.zip: build/pkg build/pkg/ext 23 | @echo "*** creating bundle at build/luadox" 24 | find build -type d -name __pycache__ -prune -exec rm -rf "{}" \; 25 | cd build/pkg && zip -q -r ../pkg.zip . 26 | 27 | .PHONY: docker 28 | docker: 29 | docker build --pull -t luadox:latest . 30 | 31 | .PHONY: release 32 | release: build/luadox 33 | cd build && tar zcf $(ARCHIVE).tar.gz luadox 34 | cd build && zip $(ARCHIVE).zip luadox 35 | 36 | .PHONY: clean 37 | clean: 38 | rm -rf build 39 | -------------------------------------------------------------------------------- /luadox/render/base.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021-2023 Jason Tackaberry 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | __all__ = ['Renderer'] 16 | 17 | import os 18 | import shutil 19 | from typing import List, Optional 20 | 21 | from ..log import log 22 | from ..parse import * 23 | from ..reference import * 24 | 25 | class Renderer: 26 | """ 27 | Base class for renderers 28 | """ 29 | def __init__(self, parser: Parser): 30 | self.parser = parser 31 | self.config = parser.config 32 | self.ctx = parser.ctx 33 | 34 | def copy_file_from_config(self, section: str, option: str, outdir: str) -> None: 35 | fname = self.config.get(section, option, fallback=None) 36 | if not fname: 37 | return 38 | if not os.path.exists(fname): 39 | log.critical('%s file "%s" does not exist', option, fname) 40 | else: 41 | shutil.copy(fname, outdir) 42 | 43 | 44 | def render(self, toprefs: List[TopRef], outdir: Optional[str]) -> None: # pyright: ignore 45 | """ 46 | Renders all toprefs to the given output directory (or file, depending on the 47 | renderer). 48 | 49 | It's the caller's obligation to have passed these toprefs through the prerenderer. 50 | """ 51 | raise NotImplemented 52 | -------------------------------------------------------------------------------- /luadox/render/yaml.py: -------------------------------------------------------------------------------- 1 | # Copyright 2023 Jason Tackaberry 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | __all__ = ['YAMLRenderer'] 16 | 17 | from typing import List 18 | 19 | import yaml 20 | try: 21 | from yaml import CDumper as Dumper 22 | except ImportError: 23 | from yaml import Dumper 24 | 25 | from ..parse import * 26 | from ..reference import * 27 | from ..utils import * 28 | from .json import JSONRenderer 29 | 30 | def str_representer(dumper: Dumper, data: str, **kwargs) -> yaml.ScalarNode: 31 | """ 32 | Represents strings containing newlins as a YAML block scalar. 33 | """ 34 | if data.count('\n') >= 1: 35 | kwargs['style'] = '|' 36 | return dumper.represent_scalar('tag:yaml.org,2002:str', data, **kwargs) 37 | 38 | 39 | class YAMLRenderer(JSONRenderer): 40 | 41 | def render(self, toprefs: List[TopRef], dst: str) -> None: 42 | """ 43 | Renders toprefs as YAML to the given output directory or file. 44 | """ 45 | # Register the custom string representer for block strings 46 | Dumper.add_representer(str, str_representer) 47 | project = self._generate(toprefs) 48 | outfile = self._get_outfile(dst, ext='.yaml') 49 | with open(outfile, 'w') as f: 50 | yaml.dump(project, stream=f, sort_keys=False, allow_unicode=True, Dumper=Dumper) 51 | -------------------------------------------------------------------------------- /luadox/data/search.js: -------------------------------------------------------------------------------- 1 | document.addEventListener("DOMContentLoaded", function() { 2 | var template = document.getElementById('template'); 3 | var body = document.getElementById('results'); 4 | var params = new URLSearchParams(window.location.search); 5 | var q = params.get('q'); 6 | if (!q) { 7 | return; 8 | } 9 | var jss = new JsSearch.Search('title'); 10 | jss.tokenizer = new JsSearch.StopWordsTokenizer(new JsSearch.SimpleTokenizer()); 11 | jss.addIndex('title'); 12 | jss.addIndex('text'); 13 | jss.addDocuments(docs); 14 | var results = jss.search(q); 15 | var summary = document.getElementsByClassName('summary')[0]; 16 | summary.innerHTML = results.length + ' results found for "' + q + '"'; 17 | var words = q.split(' '); 18 | for (var i = 0; i < results.length; i++) { 19 | var result = results[i]; 20 | var clone = template.cloneNode(true); 21 | clone.removeAttribute('id'); 22 | clone.classList.add("result-" + result.type); 23 | var title = clone.childNodes[0]; 24 | var icon = title.childNodes[0]; 25 | var link = title.childNodes[1]; 26 | var text = result.text; 27 | var title = result.title; 28 | if (text.length > 200) { 29 | text = text.slice(0, 210); 30 | var n = text.lastIndexOf(' '); 31 | if (n >= 0) { 32 | text = text.slice(0, n); 33 | } 34 | text = text + ' ...'; 35 | } 36 | // Bold search terms from text 37 | for (var j = 0; j < words.length; j++) { 38 | text = text.replace(new RegExp('(' + words[j] + ')', 'i'), '$1'); 39 | title = title.replace(new RegExp('(' + words[j] + ')', 'i'), '$1'); 40 | } 41 | icon.setAttribute('title', result.type); 42 | link.setAttribute('href', result.path); 43 | link.innerHTML = title + (result.type == 'function' ? "()" : ""); 44 | var desc = clone.childNodes[1]; 45 | desc.innerHTML = text; 46 | body.appendChild(clone); 47 | } 48 | }); -------------------------------------------------------------------------------- /luadox/assets.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021-2023 Jason Tackaberry 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import os 16 | import posixpath 17 | import glob 18 | import hashlib 19 | from typing import IO 20 | from zipfile import ZipFile 21 | 22 | class Assets: 23 | def __init__(self, path: str) -> None: 24 | try: 25 | self.zipfile = ZipFile(__loader__.archive) 26 | except AttributeError: 27 | # Not currently executing out of a zip bundle 28 | self.zipfile = None 29 | 30 | if self.zipfile: 31 | self.zippath = os.path.abspath(__loader__.archive) 32 | assert path.startswith(self.zippath + os.path.sep), 'assets path not found in zip bundle' 33 | self.path = path[len(self.zippath) + 1:].replace(os.path.sep, '/') 34 | files = [i.filename for i in self.zipfile.infolist() 35 | if i.filename.startswith(self.path) and not i.is_dir()] 36 | self._join = posixpath.join 37 | else: 38 | self.path = path 39 | self._join = os.path.join 40 | files = [f for f in glob.glob(os.path.join(path, '**'), recursive=True) if not os.path.isdir(f)] 41 | 42 | # Strip path prefix from files list. 43 | self.files = [f[len(self.path)+1:] for f in files] 44 | 45 | def open(self, fname: str) -> IO[bytes]: 46 | path = self._join(self.path, fname) 47 | if self.zipfile: 48 | return self.zipfile.open(path) 49 | else: 50 | return open(path, 'rb') 51 | 52 | def get(self, fname: str) -> bytes: 53 | with self.open(fname) as f: 54 | return f.read() 55 | 56 | def hash(self) -> str: 57 | h = hashlib.sha256() 58 | for f in sorted(self.files): 59 | h.update(self.get(f)) 60 | return h.hexdigest() 61 | 62 | assets = Assets(os.path.abspath(os.path.join(os.path.dirname(__file__), 'data'))) 63 | -------------------------------------------------------------------------------- /luadox/data/prism.css: -------------------------------------------------------------------------------- 1 | /* PrismJS 1.24.1 2 | https://prismjs.com/download.html#themes=prism-tomorrow&languages=lua */ 3 | /** 4 | * prism.js tomorrow night eighties for JavaScript, CoffeeScript, CSS and HTML 5 | * Based on https://github.com/chriskempson/tomorrow-theme 6 | * @author Rose Pritchard 7 | */ 8 | 9 | code[class*="language-"], 10 | pre[class*="language-"] { 11 | color: #ccc; 12 | background: none; 13 | font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace; 14 | font-size: 1em; 15 | text-align: left; 16 | white-space: pre; 17 | word-spacing: normal; 18 | word-break: normal; 19 | word-wrap: normal; 20 | line-height: 1.5; 21 | 22 | -moz-tab-size: 4; 23 | -o-tab-size: 4; 24 | tab-size: 4; 25 | 26 | -webkit-hyphens: none; 27 | -moz-hyphens: none; 28 | -ms-hyphens: none; 29 | hyphens: none; 30 | 31 | } 32 | 33 | /* Code blocks */ 34 | pre[class*="language-"] { 35 | padding: 1em; 36 | margin: .5em 0; 37 | overflow: auto; 38 | } 39 | 40 | :not(pre) > code[class*="language-"], 41 | pre[class*="language-"] { 42 | background: #2d2d2d; 43 | } 44 | 45 | /* Inline code */ 46 | :not(pre) > code[class*="language-"] { 47 | padding: .1em; 48 | border-radius: .3em; 49 | white-space: normal; 50 | } 51 | 52 | .token.comment, 53 | .token.block-comment, 54 | .token.prolog, 55 | .token.doctype, 56 | .token.cdata { 57 | color: #999; 58 | } 59 | 60 | .token.punctuation { 61 | color: #ccc; 62 | } 63 | 64 | .token.tag, 65 | .token.attr-name, 66 | .token.namespace, 67 | .token.deleted { 68 | color: #e2777a; 69 | } 70 | 71 | .token.function-name { 72 | color: #6196cc; 73 | } 74 | 75 | .token.boolean, 76 | .token.number, 77 | .token.function { 78 | color: #f08d49; 79 | } 80 | 81 | .token.property, 82 | .token.class-name, 83 | .token.constant, 84 | .token.symbol { 85 | color: #f8c555; 86 | } 87 | 88 | .token.selector, 89 | .token.important, 90 | .token.atrule, 91 | .token.keyword, 92 | .token.builtin { 93 | color: #cc99cd; 94 | } 95 | 96 | .token.string, 97 | .token.char, 98 | .token.attr-value, 99 | .token.regex, 100 | .token.variable { 101 | color: #7ec699; 102 | } 103 | 104 | .token.operator, 105 | .token.entity, 106 | .token.url { 107 | color: #67cdcc; 108 | } 109 | 110 | .token.important, 111 | .token.bold { 112 | font-weight: bold; 113 | } 114 | .token.italic { 115 | font-style: italic; 116 | } 117 | 118 | .token.entity { 119 | cursor: help; 120 | } 121 | 122 | .token.inserted { 123 | color: green; 124 | } 125 | 126 | -------------------------------------------------------------------------------- /luadox/prerender.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021-2023 Jason Tackaberry 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | __all__ = ['Prerenderer'] 16 | 17 | from typing import Union, Tuple, List 18 | 19 | from .log import log 20 | from .reference import * 21 | from .parse import Parser 22 | from .utils import * 23 | 24 | class Prerenderer: 25 | """ 26 | The prerender stage populates the specific typed Reference fields needed for 27 | rendering. 28 | 29 | All references are resolved to markdown links (whose target is in the form 30 | luadox:), and tags (such as @tparam) are parsed and validated. 31 | """ 32 | def __init__(self, parser: Parser): 33 | self.parser = parser 34 | self.config = parser.config 35 | self.ctx = parser.ctx 36 | 37 | def process(self) -> List[TopRef]: 38 | """ 39 | Preprocesses all Reference objects created by the parser by handling all remaining 40 | tags within content docstrings, normalizing content to markdown, and returns a 41 | sorted list of toprefs for rendering. 42 | """ 43 | toprefs: list[TopRef] = [] 44 | for ref in self.parser.topsyms.values(): 45 | if isinstance(ref, (ClassRef, ModuleRef)): 46 | self._do_classmod(ref) 47 | elif isinstance(ref, ManualRef): 48 | self._do_manual(ref) 49 | toprefs.append(ref) 50 | toprefs.sort(key=lambda ref: (ref.type, ref.symbol)) 51 | return toprefs 52 | 53 | 54 | def _do_classmod(self, topref: Union[ClassRef, ModuleRef]) -> None: 55 | has_content = False 56 | for colref in self.parser.get_collections(topref): 57 | self.ctx.update(ref=colref) 58 | 59 | # Parse out section heading and body. 60 | _, _, content = self.parser.parse_raw_content(colref.raw_content) 61 | if isinstance(colref, (ClassRef, ModuleRef)): 62 | heading = colref.symbol 63 | else: 64 | heading = content.get_first_sentence(pop=True) 65 | # Fall back to section name if there is no content for the heading. 66 | heading = heading.strip() or colref.name 67 | 68 | colref.heading = heading 69 | colref.content = content 70 | topref.collections.append(colref) 71 | 72 | functions = list(self.parser.get_elements_in_collection(FunctionRef, colref)) 73 | fields = list(self.parser.get_elements_in_collection(FieldRef, colref)) 74 | has_content = has_content or colref.content or functions or fields 75 | 76 | colref.compact = colref.flags.get('compact', []) 77 | fullnames: bool = colref.flags.get('fullnames', False) 78 | 79 | for ref in fields: 80 | self.ctx.update(ref=ref) 81 | _, _, content = self.parser.parse_raw_content(ref.raw_content) 82 | ref.title = ref.flags.get('display') or (ref.name if fullnames else ref.symbol) 83 | ref.types = ref.flags.get('type', []) 84 | ref.meta = ref.flags.get('meta') 85 | ref.content = content 86 | colref.fields.append(ref) 87 | 88 | 89 | for ref in functions: 90 | self.ctx.update(ref=ref) 91 | paramsdict, returns, content = self.parser.parse_raw_content(ref.raw_content) 92 | # args is as defined in the function definition in source, while params is 93 | # based on tags. Log a warning for any undocumented argument as long as 94 | # there is at least one documented parameter. 95 | params: List[Tuple[str, List[str], Content]] = [] 96 | # ref.extra contains the list of parameter names as parsed from the 97 | # source. Construct the params list based on 98 | for param in ref.extra: 99 | try: 100 | params.append((param, *paramsdict[param])) 101 | except KeyError: 102 | params.append((param, [], Content())) 103 | if paramsdict: 104 | log.warning('%s:%s: %s() missing @tparam for "%s" parameter', ref.file, ref.line, ref.name, param) 105 | 106 | ref.title = ref.display 107 | ref.params = params 108 | ref.returns = returns 109 | ref.meta = self.parser.refs_to_markdown(ref.flags['meta']) if 'meta' in ref.flags else '' 110 | ref.content = content 111 | colref.functions.append(ref) 112 | 113 | topref.userdata['empty'] = not has_content 114 | 115 | 116 | def _do_manual(self, topref: ManualRef) -> None: 117 | if topref.raw_content: 118 | self.ctx.update(ref=topref) 119 | # Include any preamble before the first heading. 120 | _, _, content = self.parser.parse_raw_content(topref.raw_content) 121 | topref.content = content 122 | topref.heading = self.parser.refs_to_markdown(topref.heading) 123 | for ref in self.parser.get_collections(topref): 124 | # Manuals only have SectionRefs 125 | assert(isinstance(ref, SectionRef)) 126 | self.ctx.update(ref=ref) 127 | _, _, content = self.parser.parse_raw_content(ref.raw_content) 128 | ref.heading = self.parser.refs_to_markdown(ref.heading) 129 | ref.content = content 130 | ref.level = int(ref.flags['level']) 131 | topref.collections.append(ref) 132 | 133 | -------------------------------------------------------------------------------- /luadox/data/js-search.min.js: -------------------------------------------------------------------------------- 1 | !function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e=e||self).JsSearch={})}(this,(function(e){"use strict";var t=function(){function e(){}return e.prototype.expandToken=function(e){for(var t,n=[],i=0,r=e.length;i=0&&(f=this._wrapText(f),e=e.substring(0,l)+f+e.substring(r+1),r+=n,p+=n)}return e},t._wrapText=function(e){var t=this._wrapperTagName;return"<"+t+">"+e+""},e}();e.AllSubstringsIndexStrategy=t,e.CaseSensitiveSanitizer=r,e.ExactWordIndexStrategy=n,e.LowerCaseSanitizer=o,e.PrefixIndexStrategy=i,e.Search=_,e.SimpleTokenizer=h,e.StemmingTokenizer=f,e.StopWordsMap=d,e.StopWordsTokenizer=l,e.TfIdfSearchIndex=s,e.TokenHighlighter=m,e.UnorderedSearchIndex=u,Object.defineProperty(e,"__esModule",{value:!0})})); 2 | -------------------------------------------------------------------------------- /luadox/data/prism.js: -------------------------------------------------------------------------------- 1 | /* PrismJS 1.24.1 2 | https://prismjs.com/download.html#themes=prism-dark&languages=lua */ 3 | var _self="undefined"!=typeof window?window:"undefined"!=typeof WorkerGlobalScope&&self instanceof WorkerGlobalScope?self:{},Prism=function(u){var c=/\blang(?:uage)?-([\w-]+)\b/i,n=0,e={},M={manual:u.Prism&&u.Prism.manual,disableWorkerMessageHandler:u.Prism&&u.Prism.disableWorkerMessageHandler,util:{encode:function e(n){return n instanceof W?new W(n.type,e(n.content),n.alias):Array.isArray(n)?n.map(e):n.replace(/&/g,"&").replace(/=l.reach);y+=m.value.length,m=m.next){var b=m.value;if(t.length>n.length)return;if(!(b instanceof W)){var k,x=1;if(h){if(!(k=z(v,y,n,f)))break;var w=k.index,A=k.index+k[0].length,P=y;for(P+=m.value.length;P<=w;)m=m.next,P+=m.value.length;if(P-=m.value.length,y=P,m.value instanceof W)continue;for(var E=m;E!==t.tail&&(Pl.reach&&(l.reach=N);var j=m.prev;O&&(j=I(t,j,O),y+=O.length),q(t,j,x);var C=new W(o,g?M.tokenize(S,g):S,d,S);if(m=I(t,j,C),L&&I(t,m,L),1l.reach&&(l.reach=_.reach)}}}}}}(e,a,n,a.head,0),function(e){var n=[],t=e.head.next;for(;t!==e.tail;)n.push(t.value),t=t.next;return n}(a)},hooks:{all:{},add:function(e,n){var t=M.hooks.all;t[e]=t[e]||[],t[e].push(n)},run:function(e,n){var t=M.hooks.all[e];if(t&&t.length)for(var r,a=0;r=t[a++];)r(n)}},Token:W};function W(e,n,t,r){this.type=e,this.content=n,this.alias=t,this.length=0|(r||"").length}function z(e,n,t,r){e.lastIndex=n;var a=e.exec(t);if(a&&r&&a[1]){var i=a[1].length;a.index+=i,a[0]=a[0].slice(i)}return a}function i(){var e={value:null,prev:null,next:null},n={value:null,prev:e,next:null};e.next=n,this.head=e,this.tail=n,this.length=0}function I(e,n,t){var r=n.next,a={value:t,prev:n,next:r};return n.next=a,r.prev=a,e.length++,a}function q(e,n,t){for(var r=n.next,a=0;a"+a.content+""},!u.document)return u.addEventListener&&(M.disableWorkerMessageHandler||u.addEventListener("message",function(e){var n=JSON.parse(e.data),t=n.language,r=n.code,a=n.immediateClose;u.postMessage(M.highlight(r,M.languages[t],t)),a&&u.close()},!1)),M;var t=M.util.currentScript();function r(){M.manual||M.highlightAll()}if(t&&(M.filename=t.src,t.hasAttribute("data-manual")&&(M.manual=!0)),!M.manual){var a=document.readyState;"loading"===a||"interactive"===a&&t&&t.defer?document.addEventListener("DOMContentLoaded",r):window.requestAnimationFrame?window.requestAnimationFrame(r):window.setTimeout(r,16)}return M}(_self);"undefined"!=typeof module&&module.exports&&(module.exports=Prism),"undefined"!=typeof global&&(global.Prism=Prism); 4 | Prism.languages.lua={comment:/^#!.+|--(?:\[(=*)\[[\s\S]*?\]\1\]|.*)/m,string:{pattern:/(["'])(?:(?!\1)[^\\\r\n]|\\z(?:\r\n|\s)|\\(?:\r\n|[^z]))*\1|\[(=*)\[[\s\S]*?\]\2\]/,greedy:!0},number:/\b0x[a-f\d]+(?:\.[a-f\d]*)?(?:p[+-]?\d+)?\b|\b\d+(?:\.\B|(?:\.\d*)?(?:e[+-]?\d+)?\b)|\B\.\d+(?:e[+-]?\d+)?\b/i,keyword:/\b(?:and|break|do|else|elseif|end|false|for|function|goto|if|in|local|nil|not|or|repeat|return|then|true|until|while)\b/,function:/(?!\d)\w+(?=\s*(?:[({]))/,operator:[/[-+*%^&|#]|\/\/?|<[<=]?|>[>=]?|[=~]=?/,{pattern:/(^|[^.])\.\.(?!\.)/,lookbehind:!0}],punctuation:/[\[\](){},;]|\.+|:+/}; 5 | -------------------------------------------------------------------------------- /luadox/utils.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021-2023 Jason Tackaberry 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | __all__ = [ 16 | 'Sentinel', 'Content', 'ContentFragment', 'Markdown', 'Admonition', 'SeeAlso', 17 | 'recache', 'get_first_sentence', 'get_indent_level', 'strip_trailing_comment', 18 | ] 19 | 20 | import enum 21 | import re 22 | import string 23 | from dataclasses import dataclass 24 | from functools import lru_cache 25 | from typing import Tuple, List, Callable, Optional, Pattern 26 | 27 | # Common abbreviations with periods that are considered when determining what is the 28 | # first sentence of a markdown block. 29 | ABBREV = { 30 | 'e': ('e.g.', 'eg.', 'etc.', 'et al.'), 31 | 'i': ('i.e.', 'ie.'), 32 | 'v': ('vs.',), 33 | } 34 | # Used for detecting word boundaries. Anything *not* in this set can be considered as a 35 | # word boundary. 36 | WORD_CHARS = set(string.ascii_lowercase) 37 | 38 | # Callback type used by content objects for postprocessing finalized content. Used for 39 | # converting refs to markdown links. 40 | PostProcessFunc = Optional[Callable[[str], str]] 41 | 42 | class ContentFragment: 43 | """ 44 | Base class for elements of a Content list. 45 | """ 46 | pass 47 | 48 | 49 | class Markdown(ContentFragment): 50 | """ 51 | Represents a markdown string. 52 | """ 53 | def __init__(self, value: Optional[str] = None, postprocess: Optional[PostProcessFunc]=None): 54 | # Lines accumulated via append() 55 | self._lines = [value] if value is not None else [] 56 | self._postprocess = postprocess 57 | # Cached postprocessed value 58 | # append() is called between get() calls (this case is rare or nonexistent) 59 | self._value: str|None = None 60 | 61 | def append(self, s: str) -> 'Markdown': 62 | """ 63 | Appends a line to the markdown string. Cannot be called after get(). 64 | """ 65 | assert(self._value is None) 66 | self._lines.append(s) 67 | return self 68 | 69 | def rstrip(self) -> 'Markdown': 70 | """ 71 | Removes trailing whitespace from the current set of lines added by append(). 72 | """ 73 | self._lines = ['\n'.join(self._lines).rstrip()] 74 | return self 75 | 76 | def get(self) -> str: 77 | """ 78 | Returns the final markdown string, postprocessed if a postprocessor was passed during initialization. 79 | 80 | append() cannot be called after this point. 81 | """ 82 | if self._value is None: 83 | md = '\n'.join(self._lines) 84 | if self._postprocess: 85 | md = self._postprocess(md) 86 | self._value = md 87 | del self._lines[:] 88 | return self._value 89 | 90 | 91 | @dataclass 92 | class Admonition(ContentFragment): 93 | """ 94 | A @note or @warning admonition tag. 95 | """ 96 | type: str 97 | title: str 98 | content: 'Content' 99 | 100 | 101 | @dataclass 102 | class SeeAlso(ContentFragment): 103 | """ 104 | A @see tag. 105 | """ 106 | # List of ref ids. 107 | refs: List[str] 108 | 109 | 110 | class Content(List[ContentFragment]): 111 | """ 112 | Parsed and prerendered content. The prerender stage resolves all references to 113 | 'luadox:' markdown links. 114 | 115 | Content is captured as a list of content fragments -- the most common of which is 116 | Markdown -- where fragments are different types of objects that the renderer needs to 117 | decide how to translate. 118 | """ 119 | def __init__(self, *args, postprocess: PostProcessFunc = None): 120 | super().__init__(*args) 121 | self._md_postprocess = postprocess 122 | self._first = None 123 | 124 | def get_first_sentence(self, pop=False) -> str: 125 | """ 126 | Returns the first sentence from the content. If pop is True then the content 127 | is updated in-place to remove the sentence that was returned. 128 | """ 129 | if len(self) == 0: 130 | return '' 131 | e = self[0] 132 | if not isinstance(e, Markdown): 133 | return '' 134 | first, remaining = get_first_sentence(e.get()) 135 | if pop: 136 | if remaining: 137 | self[0] = Markdown(remaining) 138 | else: 139 | self.pop(0) 140 | return first 141 | 142 | def md(self, postprocess: PostProcessFunc = None) -> Markdown: 143 | """ 144 | Convenience method that returns the last fragment in the content list if it's a 145 | Markdown, or creates and appends a new one if the last element isn't Markdown. 146 | """ 147 | if len(self) > 0 and isinstance(self[-1], Markdown): 148 | md = self[-1] 149 | assert(isinstance(md, Markdown)) 150 | else: 151 | md = Markdown(postprocess=postprocess or self._md_postprocess) 152 | self.append(md) 153 | return md 154 | 155 | 156 | class Sentinel(enum.Enum): 157 | """ 158 | Type friendly sentinel to distinguish between None and lack of argument. 159 | """ 160 | UNDEF = object() 161 | 162 | 163 | @lru_cache(maxsize=None) 164 | def recache(pattern: str, flags: int = 0) -> Pattern[str]: 165 | """ 166 | Returns a compiled regexp pattern, caching the result for subsequent invocations. 167 | """ 168 | return re.compile(pattern, flags) 169 | 170 | 171 | def get_first_sentence(s: str) -> Tuple[str, str]: 172 | """ 173 | Returns a 2-tuple of the first sentence from the given markdown, and all remaining. 174 | """ 175 | # This is fairly low level looking code, but it performs reasonably well for what it 176 | # does. 177 | l = s.lower() 178 | end = len(l) - 1 179 | last = '' 180 | n = 0 181 | while n <= end: 182 | c = l[n] 183 | if c == '\n' and last == '\n': 184 | # Treat two consecutive newlines as a sentence terminator. 185 | break 186 | elif c == '.': 187 | # Is this period followed by whitespace or EOL? 188 | if n == end or l[n+1] == ' ' or l[n+1] == '\n': 189 | # Found end-of-sentence. 190 | break 191 | elif c in ABBREV and last not in WORD_CHARS: 192 | # This character appears to start a word of an abbreviation we want to handle. 193 | # If the next set of characters matches an abbrevation variation, skip over 194 | # it. 195 | for abbr in ABBREV[c]: 196 | if l[n:n+len(abbr)] == abbr: 197 | # Subtract 1 from the abbrevation length since we're adding 1 below 198 | n += len(abbr) - 1 199 | break 200 | last = l[n] 201 | n += 1 202 | else: 203 | # Didn't break out of while loop so we weren't able to find end-of-sentence. 204 | # Consider the entire given string as the first sentence. 205 | return s, '' 206 | 207 | # If we're here, n represents the position of the end of first sentence. 208 | return s[:n], s[n+1:].strip() 209 | 210 | 211 | def get_indent_level(s: str) -> int: 212 | """ 213 | Returns the number of spaces on left side of the string. 214 | """ 215 | m = recache(r'^( *)').search(s) 216 | return len(m.group(1)) if m else 0 217 | 218 | 219 | def strip_trailing_comment(line: str) -> str: 220 | return recache(r'--.*').sub('', line) 221 | 222 | -------------------------------------------------------------------------------- /luadox/render/json.py: -------------------------------------------------------------------------------- 1 | # Copyright 2023 Jason Tackaberry 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | __all__ = ['JSONRenderer'] 16 | 17 | import json 18 | import os 19 | from typing import Tuple, List, Dict, Any 20 | 21 | from ..log import log 22 | from ..parse import * 23 | from ..reference import * 24 | from ..utils import * 25 | from .base import Renderer 26 | 27 | class JSONRenderer(Renderer): 28 | def _generate(self, toprefs: List[TopRef]) -> Dict[str, Any]: 29 | project: Dict[str, Any] = { 30 | 'apiVersion': 'v1alpha1', 31 | 'kind': 'luadox', 32 | } 33 | name = self.config.get('project', 'name', fallback=None) 34 | if name: 35 | project['name'] = name 36 | title = self.config.get('project', 'title', fallback=None) 37 | if title: 38 | project['title'] = title 39 | 40 | classes = project.setdefault('classes', []) 41 | modules = project.setdefault('modules', []) 42 | manuals = project.setdefault('manuals', []) 43 | 44 | for topref in toprefs: 45 | if isinstance(topref, ClassRef): 46 | classes.append(self._render_classmod(topref)) 47 | elif isinstance(topref, ModuleRef): 48 | modules.append(self._render_classmod(topref)) 49 | elif isinstance(topref, ManualRef): 50 | manuals.append(self._render_manual(topref)) 51 | return project 52 | 53 | def _render_content(self, content: Content) -> List[Dict[str, Any]]: 54 | output = [] 55 | for elem in content: 56 | if isinstance(elem, Markdown): 57 | md = self._render_markdown(elem) 58 | if md['value']: 59 | output.append(md) 60 | elif isinstance(elem, Admonition): 61 | output.append({ 62 | 'type': 'admonition', 63 | 'level': elem.type, 64 | 'title': elem.title, 65 | 'content': self._render_content(elem.content), 66 | }) 67 | elif isinstance(elem, SeeAlso): 68 | output.append({ 69 | 'type': 'see', 70 | 'refs': [{'refid': refid} for refid in elem.refs] 71 | }) 72 | return output 73 | 74 | def _render_types(self, types: List[str]) -> List[Dict[str, Any]]: 75 | resolved: list[dict[str, Any]] = [] 76 | for tp in types: 77 | ref = self.parser.resolve_ref(tp) 78 | if ref: 79 | resolved.append({ 80 | 'name': tp, 81 | 'refid': ref.id, 82 | }) 83 | else: 84 | resolved.append({'name': tp}) 85 | return resolved 86 | 87 | def _render_markdown(self, md: Markdown) -> Dict[str, Any]: 88 | return { 89 | 'type': 'markdown', 90 | 'value': md.get().strip(), 91 | } 92 | 93 | def _render_section(self, colref: CollectionRef, **kwargs) -> Dict[str, Any]: 94 | section: Dict[str, Any] = { 95 | 'id': colref.id, 96 | 'type': colref.type, 97 | 'symbol': colref.symbol, 98 | 'heading': colref.heading, 99 | } 100 | section.update({k:v for k, v in kwargs.items() if v}) 101 | content = self._render_content(colref.content) 102 | if content: 103 | section['content'] = content 104 | return section 105 | 106 | def _init_topref(self, topref: TopRef, **kwargs) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]: 107 | sections: List[Dict[str, Any]] = [] 108 | out: Dict[str, Any] = { 109 | 'id': topref.id, 110 | 'type': topref.type, 111 | 'name': topref.name, 112 | } 113 | # Include kwargs-provided fields ahead of sections 114 | out.update({k:v for k, v in kwargs.items() if v}) 115 | out['sections'] = sections 116 | return out, sections 117 | 118 | def _render_classmod(self, topref: TopRef) -> Dict[str, Any]: 119 | hierarchy = None 120 | if isinstance(topref, ClassRef): 121 | h = topref.hierarchy 122 | if len(h) > 1: 123 | hierarchy = [{'name': ref.name, 'refid': ref.id} for ref in h] 124 | 125 | out, sections = self._init_topref(topref, hierarchy=hierarchy) 126 | 127 | for colref in topref.collections: 128 | self.ctx.update(ref=colref) 129 | section = self._render_section(colref, compact=colref.compact) 130 | sections.append(section) 131 | 132 | fields: List[Dict[str, Any]] = [] 133 | for ref in colref.fields: 134 | field = self._render_field(ref) 135 | fields.append(field) 136 | if fields: 137 | section['fields'] = fields 138 | 139 | functions: List[Dict[str, Any]] = [] 140 | for ref in colref.functions: 141 | func = self._render_field(ref) 142 | if ref.params: 143 | params = func.setdefault('params', []) 144 | for name, types, content in ref.params: 145 | param: dict[str, Any] = {'name': name} 146 | params.append(param) 147 | if types: 148 | param['types'] = self._render_types(types) 149 | if content: 150 | param['content'] = self._render_content(content) 151 | if ref.returns: 152 | returns = func.setdefault('returns', []) 153 | for types, content in ref.returns: 154 | ret: dict[str, Any] = {} 155 | returns.append(ret) 156 | if types: 157 | ret['types'] = self._render_types(types) 158 | if content: 159 | ret['content'] = self._render_content(content) 160 | functions.append(func) 161 | if functions: 162 | section['functions'] = functions 163 | 164 | return out 165 | 166 | def _render_field(self, ref: FieldRef) -> Dict[str, Any]: 167 | field: dict[str, Any] = { 168 | 'id': ref.id, 169 | 'name': ref.name, 170 | 'display': ref.display, 171 | } 172 | if ref.types: 173 | field['types'] = self._render_types(ref.types) 174 | if ref.meta: 175 | field['meta'] = ref.meta 176 | content = self._render_content(ref.content) 177 | if content: 178 | field['content'] = content 179 | return field 180 | 181 | def _render_manual(self, topref: ManualRef) -> Dict[str, Any]: 182 | out, sections = self._init_topref(topref) 183 | for colref in topref.collections: 184 | self.ctx.update(ref=colref) 185 | section = self._render_section(colref, level=colref.level) 186 | sections.append(section) 187 | return out 188 | 189 | def _get_outfile(self, dst: str, ext: str = '.json') -> str: 190 | if not dst: 191 | dst = './luadox' + ext 192 | log.warn('"out" is not defined in config file, assuming %s', dst) 193 | if not os.path.isfile(dst) and not dst.endswith(ext): 194 | dst = os.path.join(dst, 'luadox' + ext) 195 | dirname = os.path.dirname(dst) 196 | if dirname and not os.path.exists(dirname): 197 | os.makedirs(dirname, exist_ok=True) 198 | log.info('rendering to %s', dst) 199 | return dst 200 | 201 | 202 | def render(self, toprefs: List[TopRef], dst: str) -> None: 203 | """ 204 | Renders toprefs as JSON to the given output directory or file. 205 | """ 206 | project = self._generate(toprefs) 207 | outfile = self._get_outfile(dst) 208 | with open(outfile, 'w') as f: 209 | json.dump(project, f, indent=2) 210 | -------------------------------------------------------------------------------- /luadox/tags.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021-2023 Jason Tackaberry 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import re 16 | import typing 17 | from dataclasses import dataclass, field 18 | from typing import NewType, Tuple, Type, Dict, List, Union, Generic, TypeVar, Pattern, Any, Optional, Generator 19 | 20 | from .log import log 21 | 22 | class Tag: 23 | """ 24 | Base class for all tag objects. 25 | """ 26 | # The default type name is based on the class name, but this allows subclasses to 27 | # override type name if the class name takes a different form. 28 | _type: Union[str, None] = None 29 | 30 | @property 31 | def type(self): 32 | return self._type or self.__class__.__name__.lower()[:-3] 33 | 34 | @dataclass 35 | class UnrecognizedTag(Tag): 36 | """ 37 | This is a special type that's yielded when we encounter an unrecognized tag during tag 38 | parsing. It allows the caller to decide how to handle things while not interrupting 39 | processing of other tags that occur on the same line. 40 | """ 41 | name: str 42 | 43 | # 44 | # These are the tags that influence LuaDox processing/rendering. 45 | # 46 | 47 | @dataclass 48 | class CollectionTag(Tag): 49 | name: str 50 | 51 | @dataclass 52 | class SectionTag(CollectionTag): 53 | pass 54 | 55 | @dataclass 56 | class ClassTag(CollectionTag): 57 | pass 58 | 59 | @dataclass 60 | class ModuleTag(CollectionTag): 61 | pass 62 | 63 | @dataclass 64 | class TableTag(CollectionTag): 65 | pass 66 | 67 | @dataclass 68 | class WithinTag(Tag): 69 | name: str 70 | 71 | @dataclass 72 | class FieldTag(Tag): 73 | name: str 74 | desc: str 75 | 76 | @dataclass 77 | class AliasTag(Tag): 78 | name: str 79 | 80 | @dataclass 81 | class CompactTag(Tag): 82 | elements: List[str] = field(default_factory=lambda: ['fields', 'functions']) 83 | 84 | @dataclass 85 | class FullnamesTag(Tag): 86 | pass 87 | 88 | @dataclass 89 | class InheritsTag(Tag): 90 | superclass: str 91 | 92 | @dataclass 93 | class MetaTag(Tag): 94 | value: str 95 | 96 | @dataclass 97 | class ScopeTag(Tag): 98 | name: str 99 | 100 | @dataclass 101 | class RenameTag(Tag): 102 | name: str 103 | 104 | @dataclass 105 | class DisplayTag(Tag): 106 | name: str 107 | 108 | @dataclass 109 | class TypeTag(Tag): 110 | types: List[str] 111 | 112 | @dataclass 113 | class OrderTag(Tag): 114 | whence: str 115 | anchor: Optional[str] = None 116 | 117 | 118 | # 119 | # Content tags 120 | # 121 | 122 | @dataclass 123 | class CodeTag(Tag): 124 | lang: Optional[str] = None 125 | 126 | @dataclass 127 | class UsageTag(CodeTag): 128 | pass 129 | 130 | @dataclass 131 | class ExampleTag(CodeTag): 132 | pass 133 | 134 | @dataclass 135 | class AdmonitionTag(Tag): 136 | title: Optional[str] = None 137 | 138 | @dataclass 139 | class NoteTag(AdmonitionTag): 140 | pass 141 | 142 | @dataclass 143 | class WarningTag(AdmonitionTag): 144 | pass 145 | 146 | @dataclass 147 | class SeeTag(Tag): 148 | refs: List[str] 149 | 150 | @dataclass 151 | class ParamTag(Tag): 152 | types: List[str] 153 | name: str 154 | desc: Optional[str] = None 155 | 156 | @dataclass 157 | class ReturnTag(Tag): 158 | types: List[str] 159 | desc: Optional[str] = None 160 | 161 | T = TypeVar('T') 162 | VarString = NewType('VarString', str) 163 | class PipeList(Generic[T]): 164 | pass 165 | 166 | class ParseError(ValueError): 167 | pass 168 | 169 | TagMapType = Dict[str, Tuple[Type[Tag], Dict[str, type]]] 170 | 171 | class TagParser: 172 | RE_TAG: Pattern[str] = re.compile(r'^ *@([^{]\S+) *(.*)') 173 | RE_COMMENTED_TAG: Pattern[str] = re.compile(r'^--+ *@([^{]\S+) *(.*)') 174 | 175 | # Dict that maps supported tags to their Tag class and arguments. This represents 176 | # LuaDox's annotations. See _get_tag_map(). 177 | TAGMAP: TagMapType = { 178 | 'module': (ModuleTag, {'name': str}), 179 | 'class': (ClassTag, {'name': str, 'superclass': Optional[str]}), 180 | 'section': (SectionTag, {'name': str}), 181 | 'table': (TableTag, {'name': str}), 182 | 'within': (WithinTag, {'name': str}), 183 | 'field': (FieldTag, {'name': str, 'desc': VarString}), 184 | 'alias': (AliasTag, {'name': str}), 185 | 'compact': (CompactTag, {'elements': Optional[List[str]]}), 186 | 'fullnames': (FullnamesTag, {}), 187 | 'inherits': (InheritsTag, {'superclass': str}), 188 | 'meta': (MetaTag, {'value': str}), 189 | 'scope': (ScopeTag, {'name': str}), 190 | 'rename': (RenameTag, {'name': str}), 191 | 'display': (DisplayTag, {'name': str}), 192 | 'type': (TypeTag, {'types': PipeList[str]}), 193 | 'order': (OrderTag, {'whence': str, 'anchor': Optional[str]}), 194 | 195 | 'code': (CodeTag, {'lang': Optional[str]}), 196 | 'usage': (UsageTag, {'lang': Optional[str]}), 197 | 'example': (ExampleTag, {'lang': Optional[str]}), 198 | 'warning': (WarningTag, {'title': Optional[VarString]}), 199 | 'note': (NoteTag, {'title': Optional[VarString]}), 200 | 'see': (SeeTag, {'refs': List[str]}), 201 | 'tparam': (ParamTag, { 202 | 'types': PipeList[str], 'name': str, 'desc': Optional[VarString], 203 | }), 204 | 'treturn': (ReturnTag, { 205 | 'types': PipeList[str], 'desc': Optional[VarString], 206 | }), 207 | } 208 | 209 | 210 | def __init__(self): 211 | self.tagmap = self._get_tag_map() 212 | 213 | 214 | def _get_tag_map(self) -> TagMapType: 215 | """ 216 | Returns the tag map that subsequent calls to parse() will support. Subclasses can 217 | implement to augment or replace LuaDox's annotation style. 218 | """ 219 | return self.TAGMAP 220 | 221 | 222 | def _coerce_args(self, args: List[str], types: Dict[str, type]) -> Tuple[Dict[str, Any], int]: 223 | """ 224 | Takes a raw list of string arguments and coerces them to the given types required 225 | for a tag. A dict keyed on argument name (which corresponds to the fields in the 226 | tag's dataclass) is returned. 227 | 228 | AssertionError is raised if any argument type is invalid, or if there insufficient 229 | args to satisfy the mandatory fields. 230 | """ 231 | outargs: Dict[str, Any] = {} 232 | # Convert each arg to the desired type if possible, raise if not. 233 | for n, (name, typ) in enumerate(types.items()): 234 | origin = typing.get_origin(typ) 235 | if origin == Union: 236 | subtypes = typing.get_args(typ) 237 | if type(None) in subtypes: 238 | # This is an Optional, so we tolerate it missing 239 | if n >= len(args): 240 | break 241 | typ = subtypes[0] 242 | origin = typing.get_origin(typ) 243 | else: 244 | # Internal problem, not related to user input 245 | raise NotImplemented(f'unsupported tag arg union {typ} {subtypes}') 246 | 247 | try: 248 | arg = args[n] 249 | except IndexError: 250 | raise AssertionError(f'requires at least {n+1} arguments') 251 | 252 | if typ == int: 253 | assert arg.isdigit(), f'argument {n} must be a number' 254 | outargs[name] = int(arg) 255 | elif typ == VarString: 256 | outargs[name] = ' '.join(args[n:]) 257 | return outargs, len(args) 258 | elif origin: 259 | # Handle generic types. Only the first subtype is considered. 260 | subtype = typing.get_args(typ)[0] 261 | if origin == PipeList: 262 | outargs[name] = [subtype(x) for x in arg.split('|')] 263 | elif origin == list: 264 | # Similar to VarString, consolidate everything after into a single list 265 | outargs[name] = [subtype(x) for x in args[n:]] 266 | return outargs, len(args) 267 | elif typ == str: 268 | outargs[name] = arg 269 | else: 270 | raise NotImplemented(f'unsupported tag arg type {typ}') 271 | 272 | return outargs, len(outargs) 273 | 274 | 275 | def parse(self, line: str, file: str, lineno: int, require_comment=True) -> Generator[Tag, None, None]: 276 | """ 277 | Looks for a @tag in the given raw line of code, and returns the appropriate 278 | tag object if found, or None otherwise. 279 | """ 280 | m = (self.RE_COMMENTED_TAG if require_comment else self.RE_TAG).search(line) 281 | if not m: 282 | return 283 | tag, args = m.groups() 284 | try: 285 | tagcls, argtypes = self.tagmap[tag] 286 | except KeyError: 287 | # Unrecognized tag, let caller decide by yielding this special tag type 288 | yield UnrecognizedTag(tag) 289 | return 290 | 291 | args = [arg.strip() for arg in args.split()] 292 | try: 293 | kwargs, consumed = self._coerce_args(args, argtypes) 294 | if len(args) > consumed: 295 | log.warning('%s:%s: tag @%s takes %d args but received %d, ignoring extra args', file, lineno, tag, len(argtypes), len(args)) 296 | yield from self._handle_tag(tagcls, kwargs, file, lineno) 297 | except AssertionError as e: 298 | raise ParseError(f'@{tag} is invalid: {e.args[0]}') 299 | 300 | 301 | def _handle_tag(self, tagcls: Type[Tag], kwargs: Dict[str, Any], file: str, lineno: int) -> Generator[Tag, None, None]: # pyright: ignore 302 | """ 303 | Yields one or more Tag objects given the tag class and arguments. This is where 304 | subclasses can translate/transform tags to support non-LuaDox annotations. 305 | 306 | The base class implementation handles LuaDox annotations, but also supports 307 | LuaCATS annotations when it can be done in a transparent manner. 308 | """ 309 | if tagcls == ClassTag and kwargs['name'].endswith(':'): 310 | # Support "@class name: parent" form used by LuaCATS/EmmyLua by generating 311 | # implicit @inherits. 312 | assert 'superclass' in kwargs, 'class name ends with colon but tag is missing parent class argument' 313 | yield ClassTag(name=kwargs['name'].rstrip(':')) 314 | yield InheritsTag(superclass=kwargs['superclass']) 315 | else: 316 | yield tagcls(**kwargs) 317 | 318 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /luadox/main.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021-2023 Jason Tackaberry 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | __all__ = ['main'] 16 | 17 | import os 18 | import sys 19 | 20 | # First order of business is to ensure we are running a compatible version of Python. 21 | if sys.hexversion < 0x03080000: 22 | print('FATAL: Python 3.8 or later is required.') 23 | sys.exit(1) 24 | 25 | # Add third party libraries to module path. This only applies to the zip-bundled 26 | # distribution. 27 | if hasattr(__loader__, 'archive'): 28 | sys.path.insert(0, os.path.normpath(os.path.join(os.path.dirname(__file__), '../ext'))) 29 | 30 | import re 31 | import argparse 32 | import shlex 33 | import glob 34 | import locale 35 | from configparser import ConfigParser 36 | from typing import Generator, Union, Dict, Tuple, Set 37 | 38 | from .log import log 39 | from .parse import * 40 | from .prerender import Prerenderer 41 | from .render import RENDERERS 42 | from .version import __version__ 43 | 44 | # A type used for mapping a user-defined Lua module name to a set of paths (or glob 45 | # expressions). The module name is split on '.' so the dict key is a tuple, but the 46 | # modulie name can also be None if the user didn't provide any explicit module name, in 47 | # which case the module name will be inferred. 48 | BasePathsType = Dict[Union[Tuple[str, ...], None], Set[str]] 49 | 50 | class FullHelpParser(argparse.ArgumentParser): 51 | def error(self, message: str) -> None: 52 | sys.stderr.write('error: %s\n' % message) 53 | self.print_help() 54 | sys.exit(2) 55 | 56 | 57 | def get_file_by_module(module, bases: BasePathsType) -> Union[str, None]: 58 | """ 59 | Attempts to discover the lua source file for the given module name that was 60 | required relative to the given base paths. 61 | 62 | If the .lua file was found, its full path is returned, otherwise None is 63 | returned. 64 | """ 65 | modparts = module.split('.') 66 | for aliasparts, paths in bases.items(): 67 | alias_matches = modparts[:len(aliasparts)] == list(aliasparts) if aliasparts else False 68 | for base in paths: 69 | if alias_matches and aliasparts is not None: 70 | # User-defined module name for this path matches the requested 71 | # module name. Strip away the intersecting components of the 72 | # module name and check this path for what's left. For example, 73 | # if we're loading foo.bar, alias=foo, base=../src, then we 74 | # check ../src/bar.lua. 75 | remaining = modparts[len(aliasparts):] 76 | p = os.path.join(base, *remaining) + '.lua' 77 | if os.path.exists(p): 78 | return os.path.abspath(p) 79 | # No module name alias, or alias didn't match the requested module. 80 | # First treat the requested module as immediately subordinate to 81 | # the given path. For example, we're loading foo.bar and base is 82 | # ../src, then we check ../src/foo/bar.lua 83 | p = os.path.join(base, *modparts) + '.lua' 84 | if os.path.exists(p): 85 | return os.path.abspath(p) 86 | # Next check to see if the first component of the module name is 87 | # the same as the base directory name and if so strip it off and 88 | # look for remaining. For example, we're loading foo.bar and base is 89 | # ../foo, then we check ../foo/bar.lua 90 | baseparts = os.path.split(base) 91 | if modparts[0] == baseparts[-1]: 92 | p = os.path.join(base, *modparts[1:]) + '.lua' 93 | if os.path.exists(p): 94 | return p 95 | 96 | 97 | def crawl(parser: Parser, path: str, follow: bool, seen: Set[str], bases: BasePathsType, encoding: str) -> None: 98 | """ 99 | Parses all Lua source files starting with the given path and recursively 100 | crawling all files referenced in the code via 'require' statements. 101 | """ 102 | if os.path.isdir(path): 103 | # Passing a directory implies follow 104 | follow = True 105 | path = os.path.join(path, 'init.lua') 106 | if not os.path.exists(path): 107 | log.critical('directory given, but %s does not exist', path) 108 | sys.exit(1) 109 | path = os.path.abspath(path) 110 | if path in seen: 111 | return 112 | seen.add(path) 113 | log.info('parsing %s', path) 114 | requires = parser.parse_source(open(path, encoding=encoding)) 115 | if follow: 116 | for r in requires: 117 | newpath = get_file_by_module(r, bases) 118 | if not newpath: 119 | log.error('could not discover source file for module %s', r) 120 | else: 121 | crawl(parser, newpath, follow, seen, bases, encoding) 122 | 123 | 124 | def get_config(args: argparse.Namespace) -> ConfigParser: 125 | """ 126 | Consolidates command line arguments and config file, returning a ConfigParser 127 | instance that has the reconciled configuration such that command line arguments 128 | take precedence 129 | """ 130 | config = ConfigParser(inline_comment_prefixes='#') 131 | config.add_section('project') 132 | config.add_section('manual') 133 | if args.config: 134 | if not os.path.exists(args.config): 135 | log.fatal('config file "%s" does not exist', args.config) 136 | sys.exit(1) 137 | config.read_file(open(args.config)) 138 | if args.files: 139 | config.set('project', 'files', '\n'.join(args.files)) 140 | if args.nofollow: 141 | config.set('project', 'follow', 'false') 142 | for prop in ('name', 'out', 'css', 'favicon', 'encoding', 'hometext', 'renderer'): 143 | if getattr(args, prop): 144 | config.set('project', prop, getattr(args, prop)) 145 | if args.manual: 146 | for spec in args.manual: 147 | id, fname = spec.split('=') 148 | config.set('manual', id, fname) 149 | return config 150 | 151 | 152 | def get_files(config: ConfigParser) -> Generator[Tuple[str, str], None, None]: 153 | """ 154 | Generates the files/directories to parse based on config. 155 | """ 156 | filelines = config.get('project', 'files', fallback='').strip().splitlines() 157 | for line in filelines: 158 | for spec in shlex.split(line): 159 | for modalias, globexpr in re.findall(r'(?:([^/\\]+)=)?(.*)', spec): 160 | for fname in glob.glob(globexpr): 161 | yield modalias, fname 162 | 163 | 164 | def main(): 165 | global config 166 | renderer_names = ', '.join(RENDERERS) 167 | p = FullHelpParser(prog='luadox') 168 | p.add_argument('-c', '--config', type=str, metavar='FILE', 169 | help='Luadox configuration file') 170 | p.add_argument('-n', '--name', action='store', type=str, metavar='NAME', 171 | help='Project name (default Lua Project)') 172 | p.add_argument('--hometext', action='store', type=str, metavar='TEXT', 173 | help='Home link text on the top left of every page') 174 | p.add_argument('-r', '--renderer', action='store', type=str, metavar='TYPE', 175 | help=f'How to render the parsed content: {renderer_names} ' 176 | '(default: html)') 177 | p.add_argument('-o', '--out', action='store', type=str, metavar='PATH', 178 | help='Target path for rendered files, with directories created ' 179 | 'if necessary. For single-file renderers (e.g. json), this is ' 180 | ' treated as a file path if it ends with the appropriate extension ' 181 | '(e.g. .json) (default: ./out/ for multi-file renderers, or ' 182 | 'luadox. for single-file renderers)') 183 | p.add_argument('-m', '--manual', action='store', type=str, metavar='ID=FILENAME', nargs='*', 184 | help='Add manual page in the form id=filename.md') 185 | p.add_argument('--css', action='store', type=str, metavar='FILE', 186 | help='Custom CSS file (html renderer)') 187 | p.add_argument('--favicon', action='store', type=str, metavar='FILE', 188 | help='Path to favicon file (html renderer)') 189 | p.add_argument('--nofollow', action='store_true', 190 | help="Disable following of require()'d files (default false)") 191 | p.add_argument('--encoding', action='store', type=str, metavar='CODEC', default=None, 192 | help='Character set codec for input (default {})'.format(locale.getpreferredencoding())) 193 | p.add_argument('files', type=str, metavar='[MODNAME=]FILE', nargs='*', 194 | help='List of files to parse or directories to crawl with optional module name alias') 195 | p.add_argument('--version', action='version', version='%(prog)s ' + __version__) 196 | 197 | args = p.parse_args() 198 | config = get_config(args) 199 | files = list(get_files(config)) 200 | if not files: 201 | # Files are mandatory 202 | log.critical('no input files or directories specified on command line or config file') 203 | sys.exit(1) 204 | 205 | renderer = config.get('project', 'renderer', fallback='html') 206 | try: 207 | rendercls = RENDERERS[renderer] 208 | except KeyError: 209 | log.error('unknown renderer "%s", valid types are: %s', renderer, renderer_names) 210 | sys.exit(1) 211 | 212 | # Derive a set of base paths based on the input files that will act as search 213 | # paths for crawling 214 | bases: BasePathsType = {} 215 | for alias, fname in files: 216 | fname = os.path.abspath(fname) 217 | aliasparts = tuple(alias.split('.')) if alias else None 218 | paths = bases.setdefault(aliasparts, set()) 219 | paths.add(fname if os.path.isdir(fname) else os.path.dirname(fname)) 220 | 221 | parser = Parser(config) 222 | encoding = config.get('project', 'encoding', fallback=locale.getpreferredencoding()) 223 | try: 224 | # Parse given files/directories, with following if enabled. 225 | follow = config.get('project', 'follow', fallback='true').lower() in ('true', '1', 'yes') 226 | seen: set[str] = set() 227 | for _, fname in files: 228 | crawl(parser, fname, follow, seen, bases, encoding) 229 | pages = config.items('manual') if config.has_section('manual') else [] 230 | for scope, path in pages: 231 | parser.parse_manual(scope, open(path, encoding=encoding)) 232 | except Exception as e: 233 | msg = f'error parsing around {parser.ctx.file}:{parser.ctx.line}: {e}' 234 | if isinstance(e, ParseError): 235 | log.error(msg) 236 | else: 237 | log.exception(f'unhandled {msg}') 238 | sys.exit(1) 239 | 240 | # LuaDox v1 fallback 241 | outdir = config.get('project', 'outdir', fallback=None) 242 | # LuaDox v2 just calls it 'out' 243 | out = config.get('project', 'out', fallback=outdir) 244 | 245 | renderer = rendercls(parser) 246 | try: 247 | log.info('prerendering %d pages', len(parser.topsyms)) 248 | toprefs = Prerenderer(parser).process() 249 | renderer.render(toprefs, out) 250 | except Exception as e: 251 | log.exception('unhandled error rendering around %s:%s: %s', parser.ctx.file, parser.ctx.line, e) 252 | sys.exit(1) 253 | 254 | log.info('done') 255 | -------------------------------------------------------------------------------- /luadox/data/luadox.css: -------------------------------------------------------------------------------- 1 | :target { 2 | background-color: #ffe080 !important; 3 | color: black !important; 4 | border-bottom: 1px solid #cca940 !important; 5 | border-top: 1px solid #cca940 !important; 6 | } 7 | 8 | body { 9 | font-family: sans-serif; 10 | color: black; 11 | padding: 0px; 12 | margin: 0; 13 | } 14 | 15 | 16 | div.topbar { 17 | box-sizing: border-box; 18 | background-color: #465158; 19 | position: fixed; 20 | display: flex; 21 | align-items: center; 22 | left: 0px; 23 | top: 0px; 24 | width: 100%; 25 | height: 40px; 26 | padding: 0 1em 0 0.4em; 27 | z-index: 10; 28 | font-size: 90%; 29 | color: white; 30 | box-shadow: 0 0px 15px 5px rgba(0, 0, 0, 0.4); 31 | } 32 | 33 | div.topbar div.group { 34 | display: flex; 35 | flex: 1; 36 | } 37 | 38 | div.topbar div.group.one { 39 | justify-content: flex-start; 40 | } 41 | 42 | div.topbar div.group.two { 43 | justify-content: center; 44 | } 45 | div.topbar div.group.three { 46 | justify-content: flex-end; 47 | } 48 | 49 | div.topbar a { 50 | color: white; 51 | border: none; 52 | padding: 0.5em 1em; 53 | } 54 | 55 | div.topbar div.description a { 56 | font-size: 100% !important; 57 | font-weight: normal !important; 58 | margin-left: 0 !important; 59 | } 60 | 61 | div.topbar div.button a { 62 | margin: 0.375em 0; 63 | font-size: 80%; 64 | font-weight: bold; 65 | white-space: nowrap; 66 | display: flex; 67 | align-items: center; 68 | } 69 | 70 | div.topbar div.button.solid a { 71 | margin-left: 1em; 72 | } 73 | 74 | div.topbar div.button.solid a, 75 | div.topbar div.button a:hover { 76 | background-color: #697983; 77 | text-shadow: 0 1px rgba(0, 0, 0, 0.5); 78 | border-radius: 3px; 79 | border: none; 80 | } 81 | div.topbar div.button.solid a:hover { 82 | background-color: #8aa0ad; 83 | } 84 | 85 | div.topbar div.button a img { 86 | width: 1.5em; 87 | } 88 | 89 | div.topbar div.button a img[src*='.svg'] { 90 | filter: invert(1); 91 | } 92 | 93 | div.topbar div.button.iconleft a { 94 | padding-left: 0.6em; 95 | padding-right: 1em; 96 | } 97 | 98 | div.topbar div.button.iconright a { 99 | padding-left: 1em; 100 | padding-right: 0.6em; 101 | } 102 | 103 | div.topbar div.button.iconleft a img { 104 | padding-right: 0.5em; 105 | } 106 | div.topbar div.button.iconright a img { 107 | padding-left: 0.5em; 108 | } 109 | 110 | div.sidebar { 111 | clear: both; 112 | box-sizing: border-box; 113 | background-color: #e8ecef; 114 | width: 15em; 115 | height: calc(100% - 40px); 116 | padding: 1em 0.5em; 117 | border-right: 1px solid #ccc; 118 | position: fixed; 119 | overflow-y: auto; 120 | } 121 | 122 | 123 | div.body { 124 | margin-left: 15em; 125 | margin-top: 2.5em; 126 | } 127 | 128 | div.sidebar div.heading { 129 | font-weight: bold; 130 | } 131 | 132 | div.sidebar ul { 133 | list-style-type: none; 134 | padding-left: 1em; 135 | margin-top: 0.5em; 136 | } 137 | div.sidebar li { 138 | padding: 2px 0; 139 | } 140 | 141 | div.sidebar li.selected { 142 | font-weight: bold; 143 | } 144 | 145 | div.sidebar div.sections li { 146 | padding-left: 1em; 147 | text-indent: -1em; 148 | } 149 | 150 | div.sidebar p, 151 | h2 p { 152 | margin: 0; 153 | padding: 0; 154 | display: inline; 155 | } 156 | 157 | a { 158 | color: #444; 159 | text-decoration: none; 160 | border-bottom: 1px solid #ccc; 161 | } 162 | 163 | a:hover { 164 | color: #000; 165 | border-bottom: 1px solid #666; 166 | } 167 | 168 | div.section, div.body div.manual { 169 | padding: 0em 1em 1em 1em; 170 | } 171 | 172 | div.body pre { 173 | margin: 0 2em; 174 | } 175 | 176 | div.manual h1 { 177 | margin: 0 -16px 0 -20px; 178 | padding: 10px 10px 10px 20px; 179 | font-size: 120%; 180 | } 181 | 182 | div.section h2 { 183 | margin: 0 -16px 0 -20px; 184 | padding: 10px 10px 10px 20px; 185 | background-color: #eeeeee; 186 | border-top: 1px solid #ccc; 187 | border-bottom: 1px solid #ccc; 188 | font-size: 120%; 189 | } 190 | 191 | div.manual h2, 192 | div.manual h3 { 193 | margin: 1em -16px 0 -20px; 194 | padding: 10px 10px 10px 20px; 195 | } 196 | 197 | div.manual h2 { 198 | background-color: #eeeeee; 199 | border-top: 1px solid #ccc; 200 | border-bottom: 1px solid #ccc; 201 | font-size: 120%; 202 | padding: 10px 10px 10px 20px; 203 | } 204 | 205 | div.manual h3 { 206 | font-size: 110%; 207 | padding: 5px 5px 5px 20px; 208 | /* Ensures when :target applies (which adds borders) we don't affect layout */ 209 | border-top: 1px solid transparent; 210 | border-bottom: 1px solid transparent; 211 | } 212 | 213 | div.hierarchy { 214 | margin-top: 2em; 215 | margin-bottom: 2em; 216 | } 217 | 218 | div.hierarchy div.heading { 219 | font-weight: bold; 220 | margin-bottom: 0.5em; 221 | } 222 | div.hierarchy ul { 223 | margin-top: 0.5em; 224 | padding-left: 1em; 225 | } 226 | 227 | div.hierarchy li { 228 | list-style-type: none; 229 | margin: 0.4em 0; 230 | } 231 | div.hierarchy li span { 232 | padding: 0.2em; 233 | font-family: "Consolas", "Deja Vu Sans Mono", "Bitstream Vera Sans Mono", monospace; 234 | font-size: 0.95em; 235 | letter-spacing: 0.01em; 236 | } 237 | div.hierarchy li span em { 238 | font-style: normal; 239 | } 240 | 241 | div.hierarchy li.self span { 242 | background-color: #fbedc3; 243 | } 244 | 245 | h1, 246 | div.section h2.class, 247 | div.section h2.module { 248 | color: white; 249 | background-color: #555; 250 | border-top: 1px solid #222; 251 | border-bottom: 1px solid #222; 252 | font-size: 140%; 253 | } 254 | 255 | 256 | code { 257 | font-family: 'Courier New', monospace; 258 | font-size: 0.95em; 259 | } 260 | 261 | var, a code { 262 | letter-spacing: 0.01em; 263 | font-weight: bold; 264 | font-style: normal; 265 | color: #444; 266 | } 267 | 268 | div.see { 269 | padding-top: 0.5em; 270 | } 271 | 272 | div.see::before { 273 | content: "👉 "; 274 | } 275 | 276 | h3.fields, h3.functions { 277 | padding-top: 1em; 278 | padding-bottom: 0.5em !important; 279 | } 280 | dl p:first-child { 281 | margin-top: 0.5em; 282 | } 283 | dl dd:not(:last-child), 284 | dl dd:not(:last-child) { 285 | padding-bottom: 1.5em; 286 | /*border-bottom: 1px solid #ddd;*/ 287 | } 288 | 289 | 290 | dt { 291 | padding: 0.5em; 292 | margin-left: -1em; 293 | margin-right: -1em; 294 | /* Ensures when :target applies (which adds borders) we don't affect layout */ 295 | border-top: 1px solid transparent; 296 | border-bottom: 1px solid transparent; 297 | } 298 | dt var { 299 | font-size: 1.15em; 300 | } 301 | dd { 302 | margin-left: 2.5em; 303 | } 304 | 305 | dl.functions div.heading { 306 | margin-top: 1em; 307 | margin-left: 0em; 308 | margin-bottom: 0.5em; 309 | font-style: italic; 310 | } 311 | 312 | 313 | table.parameters, 314 | table.returns { 315 | max-width: 90%; 316 | border-top: 1px solid #ccc; 317 | border-bottom: 1px solid #ccc; 318 | border-left: transparent; 319 | border-right: transparent; 320 | margin-left: 1em; 321 | border-collapse: collapse; 322 | } 323 | table.parameters td, 324 | table.returns td { 325 | padding: 0.4em 0; 326 | vertical-align: top; 327 | } 328 | 329 | table.parameters tr:not(:last-child) td, 330 | table.returns tr:not(:last-child) td { 331 | border-bottom: 1px solid #ccc; 332 | } 333 | 334 | table.parameters tr, 335 | table.returns tr { 336 | background: #f7f7f7; 337 | } 338 | 339 | 340 | table.parameters tr td:first-child, 341 | table.returns tr td:first-child { 342 | text-align: right; 343 | padding-left: 1em; 344 | } 345 | 346 | table.parameters tr td:last-child, 347 | table.returns tr td:last-child { 348 | padding-right: 1em; 349 | } 350 | 351 | table.parameters td.name, 352 | table.parameters td.types, 353 | table.returns td.name, 354 | table.returns td.types { 355 | white-space: nowrap; 356 | padding-right: 1em !important; 357 | } 358 | 359 | 360 | * { 361 | scroll-padding-top: 40px; 362 | } 363 | 364 | div.section div.inner { 365 | min-width: 20em; 366 | max-width: 80em; 367 | } 368 | 369 | div.synopsis h3 { 370 | display: none; 371 | } 372 | 373 | div.synopsis div.heading { 374 | font-weight: bold; 375 | margin-top: 1em; 376 | margin-bottom: 0em; 377 | margin-left: 0.5em; 378 | } 379 | 380 | div.synopsis table { 381 | border-top: 1px solid #d2b089; 382 | border-bottom: 1px solid #d2b089; 383 | background-color: #f8f0e6; 384 | width: 95%; 385 | margin: 1em auto; 386 | border-collapse: collapse; 387 | } 388 | div.synopsis td { 389 | padding: 0.3em 0.5em 0.3em 0.5em; 390 | vertical-align: top; 391 | } 392 | div.synopsis tr:not(:last-child) td { 393 | border-bottom: 1px solid #e2d0ba; 394 | } 395 | 396 | div.synopsis td:first-child { 397 | /* min-width: 15em; 398 | max-width: 20em; */ 399 | white-space: nowrap; 400 | padding-right: 1em; 401 | } 402 | 403 | div.synopsis td.meta, 404 | div.synopsis td.meta a { 405 | white-space: nowrap; 406 | color: #777777; 407 | } 408 | 409 | div.synopsis td > p, 410 | table.parameters td > p, 411 | table.returns td > p { 412 | margin: 0; 413 | } 414 | 415 | div.synopsis td > p:not(:first-child) { 416 | margin-top: 0.5em; 417 | } 418 | 419 | div.synopsis td a.permalink { 420 | color: #aaa; 421 | } 422 | 423 | 424 | div.admonition { 425 | border: 1px solid #609060; 426 | background-color: #e9ffe9; 427 | width: 90%; 428 | margin: 1.5em auto; 429 | } 430 | 431 | div.admonition div.title { 432 | margin: 0; 433 | margin-top: 0px; 434 | padding: 0.3em 0 0.3em 0.5em; 435 | color: white; 436 | font-weight: bold; 437 | font-size: 1.0em; 438 | text-shadow: 0 1px rgba(0, 0, 0, 0.5); 439 | } 440 | 441 | div.admonition div.body { 442 | margin: 0.5em 1em 0.5em 1em; 443 | padding: 0; 444 | } 445 | 446 | 447 | div.warning { 448 | border: 1px solid #900000; 449 | background-color: #ffe9e9; 450 | } 451 | 452 | div.warning > div.title { 453 | background-color: #b04040; 454 | border-bottom: 1px solid #900000; 455 | } 456 | 457 | div.note > div.title { 458 | background-color: #70A070; 459 | border-bottom: 1px solid #609060; 460 | } 461 | 462 | dl.fields dt span.icon::after { 463 | content: "🏷️ "; 464 | vertical-align: middle; 465 | font-size: 110%; 466 | } 467 | 468 | dl.fields dt span.tag, 469 | dl.functions dt span.tag { 470 | border-radius: 20px; 471 | border: 1px solid #ccc; 472 | background-color: #eee; 473 | display: inline; 474 | opacity: 0.6; 475 | padding: 5px 10px; 476 | font-size: 80%; 477 | margin: 0 0.5em; 478 | } 479 | 480 | dl.fields dt span.tag:first-of-type, 481 | dl.functions dt span.tag:first-of-type { 482 | margin-left: 2em; 483 | } 484 | 485 | dl.fields dt span.meta::before, 486 | dl.functions dt span.meta::before { 487 | filter: saturate(0); 488 | opacity: 0.9; 489 | content: "👁️"; 490 | padding-right: 0.5em; 491 | } 492 | 493 | dl.fields dt span.type::before { 494 | filter: saturate(0); 495 | opacity: 0.9; 496 | content: "✏️"; 497 | padding-right: 0.5em; 498 | } 499 | 500 | dl.functions dt span.icon::after { 501 | content: "🏃‍♂️ "; 502 | vertical-align: middle; 503 | font-size: 140%; 504 | } 505 | 506 | a.permalink:hover { 507 | color: #c60f0f !important; 508 | border: none; 509 | } 510 | 511 | a.permalink { 512 | color: #ccc; 513 | font-size: 1em; 514 | margin-left: 6px; 515 | padding: 0 4px 0 4px; 516 | text-decoration: none; 517 | border: none; 518 | visibility: hidden; 519 | } 520 | 521 | h1:hover > a.permalink, 522 | h2:hover > a.permalink, 523 | h3:hover > a.permalink, 524 | h4:hover > a.permalink, 525 | td:hover > a.permalink, 526 | td:hover > div > a.permalink, 527 | dt:hover > a.permalink { 528 | visibility: visible; 529 | } 530 | 531 | pre.language-lua { 532 | border-radius: 6px; 533 | } 534 | 535 | dd table { 536 | } 537 | 538 | input.search { 539 | width: calc(100% - 2.5em); 540 | opacity: 0.7; 541 | margin: 0 1em 1em 1em; 542 | } 543 | 544 | input.search:focus { 545 | opacity: 1.0; 546 | } 547 | 548 | div#template { 549 | display: none; 550 | } 551 | 552 | div#results { 553 | padding: 1em; 554 | max-width: 650px; 555 | } 556 | 557 | div.result { 558 | margin-bottom: 35px; 559 | font-family: arial, sans-serif; 560 | } 561 | 562 | div.result div.title { 563 | font-size: 20px; 564 | line-height: 1.3; 565 | } 566 | 567 | div.result div.text { 568 | line-height: 1.58; 569 | margin-left: 2em; 570 | } 571 | 572 | div.summary { 573 | color: #666666; 574 | font-size: 90%; 575 | margin-bottom: 1em; 576 | } 577 | 578 | div.result span { 579 | font-size: 90%; 580 | } 581 | 582 | div.result.result-class span::after { 583 | content: "🧱 "; 584 | } 585 | div.result.result-module span::after { 586 | content: "📦 "; 587 | } 588 | div.result.result-field span::after { 589 | content: "🏷️ "; 590 | } 591 | div.result.result-function span::after { 592 | content: "🏃‍♂️ "; 593 | } 594 | div.result.result-section span::after { 595 | content: "📓 "; 596 | } 597 | div.result b { 598 | background-color: #faffb8; 599 | } 600 | 601 | table.user { 602 | border-collapse: collapse; 603 | margin: 1em; 604 | font-family: sans-serif; 605 | min-width: 400px; 606 | box-shadow: 0 0 20px rgba(0, 0, 0, 0.15); 607 | border-radius: 10px; 608 | border-collapse: collapse; 609 | overflow: hidden; 610 | } 611 | 612 | table.user thead tr { 613 | background-color: #3a5e75; 614 | color: #ffffff; 615 | text-align: left; 616 | } 617 | 618 | table.user th, 619 | table.user td { 620 | padding: 12px 15px; 621 | } 622 | 623 | table.user tbody tr { 624 | border-bottom: 1px solid #dddddd; 625 | } 626 | 627 | table.user tbody tr:nth-of-type(even) { 628 | background-color: #f3f3f3; 629 | } 630 | 631 | table.user tbody tr:last-of-type { 632 | border-bottom: 3px solid #3a5e75; 633 | } 634 | -------------------------------------------------------------------------------- /luadox/reference.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021-2023 Jason Tackaberry 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | __all__ = [ 16 | 'RefT', 'RawContentType', 'Reference', 'CollectionRef', 'TopRef', 17 | 'ModuleRef', 'ClassRef', 'ManualRef', 'SectionRef', 'TableRef', 18 | 'FunctionRef', 'FieldRef' 19 | ] 20 | 21 | import hashlib 22 | import re 23 | from dataclasses import dataclass, field, fields 24 | from typing import TypeVar, Optional, Union, List, Tuple, Dict, Any 25 | 26 | from .log import log 27 | from .tags import Tag 28 | from .utils import Content 29 | 30 | # Used for generics taking Reference types 31 | RefT = TypeVar('RefT', bound='Reference') 32 | RawContentType = List[Tuple[int, str, Union[List[Tag], None]]] 33 | 34 | @dataclass 35 | class Reference: 36 | """ 37 | Reference is the base class to anything that can be, uh, referenced. It is the basis 38 | of all documentable elements, and applies to modules, classes, fields, functions, 39 | sections, manual pages, etc. 40 | 41 | A special type of reference called a top-level reference -- or "topref" -- is anything 42 | that will be rendered into its own separate page in the documentation. All references 43 | can be traced back to a topref. 44 | 45 | A Reference can be globally resolved by the combination of its topref (determines 46 | the page the ref exists on) and its name (which uniquely identifies the ref on the 47 | top-level page). 48 | 49 | One of the Reference subclasses should normally be used (e.g. ClassRef), which are 50 | considered typed references. Reference itself however may be directly instantiated 51 | (called an untyped reference) and later converted to a typed reference by using the 52 | clone_from() class method on one of the subclasses. 53 | """ 54 | # 55 | # All Reference instances must have values assigned, so for type purposes we don't 56 | # allow None, although we'll initialize to the zero value for that type. 57 | # 58 | 59 | # The refs dict from the Parser object that created us. Used to resolve ancestor 60 | # references (topref and hierarchy) 61 | parser_refs: Dict[str, 'Reference'] 62 | # Lua source file the ref was parsed from 63 | file: str 64 | # Default type, subclasses override 65 | type: str = '' 66 | # The original as-parsed name of the reference. This is like the name property 67 | # but whereas name is normalized (e.g. Class:method is normalized to Class.method), 68 | # the symbol is how it appears in code (e.g. Class:method) and is used for 69 | # display purposes. All References must have symbols. 70 | symbol: str = '' 71 | # Whether this is an implicitly generated module reference (i.e. a module that 72 | # lacks a @module tag but yet has documented elements). 73 | implicit: bool = False 74 | # Number of nested scope levels this reference belongs to, where -1 is an implicit 75 | # module. Note that this is different from flags['level'] (which indicates the 76 | # level of a heading). 77 | level: int = 0 78 | 79 | # 80 | # These are optional attributes, which are only set depending on the type. 81 | # 82 | 83 | # Line number from the above file where the ref was declared 84 | line: Optional[int] = None 85 | # A stack of Reference objects this ref is contained within. Used to resolve names by 86 | # crawling up the scope stack. 87 | scopes: Optional[List['Reference']] = None 88 | # Name of symbol for @within 89 | within: Optional[str] = None 90 | # The collection the ref belongs to 91 | collection: Optional['Reference'] = None 92 | 93 | # A dict that can be used from the outside to store some external metadata 94 | # about the Reference. For example, Parser._add_reference() uses it to 95 | # determine of the ref had already been added, and the pre-render stage uses 96 | # it to store a flag as to whether the ref has any renderable content. 97 | userdata: Dict[str, Any] = field(default_factory=dict) 98 | # Contextual information depending on type (e.g. for functions it's information 99 | # about arguments). 100 | extra: List[str] = field(default_factory=list) 101 | # A list of lines containing the documented content for this collection. Each element 102 | # is a 2-tuple in the form (line number, text) where line number is the specific line 103 | # in self.file where the comment appears, and text is in markdown format. 104 | raw_content: RawContentType = field(default_factory=list) 105 | # The processed (from raw) content which is set during the prerender stage 106 | content: Content = field(default_factory=Content) 107 | # A map of modifiers that apply to this Reference that affect how it is rendered, 108 | # mostly from @tags. These are accumulated in the flags dict until all parsing 109 | # is done and then the parser process stage will convert these to proper fields in the 110 | # respective typed ref. 111 | flags: Dict[str, Any] = field(default_factory=dict) 112 | 113 | @classmethod 114 | def clone_from(cls, ref: 'Reference', **kwargs) -> 'Reference': 115 | """ 116 | Creates a new Reference instance that clones attributes from the given ref object, 117 | and sets (or overrides) attributes via kwargs. 118 | 119 | This method can be used to create a typed reference (instance of a Reference 120 | subclass) from an untyped reference (Reference instance). 121 | """ 122 | # We must only clone fields that are allowed by the target class. 123 | allowed = {f.name for f in fields(cls)} 124 | args = { 125 | k: v for k, v in ref.__dict__.items() 126 | if k in allowed and k[0] != '_' and k != 'type' 127 | } 128 | args.update(kwargs) 129 | return cls(**args) 130 | 131 | def __post_init__(self): 132 | # Fully scoped and normalized reference name (cached from _set_name()) 133 | self._name: str|None = None 134 | # Original symbol as 135 | self._symbol: str|None = None 136 | # Name of the top-level symbol this Reference belongs to (cached from 137 | # _set_topsym()) 138 | self._topsym: str|None = None 139 | # Display name of the Reference name (cached from _set_name()) 140 | self._display: str|None = None 141 | 142 | def __str__(self) -> str: 143 | return '{}(type={}, _name={}, symbol={}, file={}, line={} impl={})'.format( 144 | self.__class__.__name__, 145 | self.type, self._name, self.symbol, self.file, self.line, self.implicit 146 | ) 147 | 148 | def clear_cache(self): 149 | self._name = None 150 | self._symbol = None 151 | self._topsym = None 152 | self._display = None 153 | 154 | @property 155 | def scope(self) -> Union['Reference', None]: 156 | """ 157 | The immediate scope of the Reference, or None if this Reference has no containing 158 | scope. 159 | 160 | Scopes are modules, classes, tables, or manual pages. Other ref types such as 161 | sections can't be scopes. 162 | 163 | Implicit modules and manual pages are the only typed refs without a scope. Other 164 | top refs (classes and explicit modules) will have the implicit module as their 165 | scope. 166 | """ 167 | return self.scopes[-1] if self.scopes else None 168 | 169 | @property 170 | def name(self) -> str: 171 | """ 172 | The fully qualified proper name by which this Reference can be linked. The 173 | name is not necessarily globally unique, but *is* unique within its topref. 174 | """ 175 | if not self._name: 176 | self._set_name() 177 | assert(self._name) 178 | return self._name 179 | 180 | @property 181 | def id(self) -> str: 182 | """ 183 | A globally unique opaque identifier of the Reference. 184 | """ 185 | # Current implementation hashes this string to prevent consumers of the json/yaml 186 | # render output from parsing/interpreting the id. This consequently does add some 187 | # risk of collision, but 160 bits with BLAKE2 should be enough to make this 188 | # exceptionally unlikely. 189 | s = f'{self.topref.type}#{self.topsym}#{self.name}' 190 | return hashlib.blake2b(s.encode(), digest_size=20).hexdigest() 191 | 192 | @property 193 | def topsym(self) -> str: 194 | """ 195 | Returns the symbol name of our top-level reference. 196 | 197 | This does *not* honor @within. 198 | """ 199 | if not self._topsym: 200 | self._set_topsym() 201 | assert(self._topsym) 202 | return self._topsym 203 | 204 | @property 205 | def topref(self) -> 'Reference': 206 | """ 207 | Returns the Reference object for the top-level reference this ref 208 | belongs to. If we're already a top-level Ref (e.g. class or module) 209 | then self is returned. 210 | 211 | This does *not* honor @within. 212 | """ 213 | # If there are no scopes, we *are* the topref 214 | return self if not self.scopes else self.parser_refs[self.topsym] 215 | 216 | @property 217 | def display(self) -> str: 218 | if not self._display: 219 | self._set_name() 220 | assert(self._display is not None) 221 | return self._display 222 | 223 | @property 224 | def display_compact(self) -> str: 225 | """ 226 | Compact form of display name (topsym stripped) 227 | """ 228 | display: str|None = self.flags.get('display') 229 | if display: 230 | return display 231 | else: 232 | assert(isinstance(self.symbol, str)) 233 | assert(isinstance(self.topsym, str)) 234 | if self.symbol.startswith(self.topsym): 235 | return self.symbol[len(self.topsym):].lstrip(':.') 236 | else: 237 | return self.symbol 238 | 239 | def _apply_rename(self) -> None: 240 | """ 241 | Applies a @rename tag to the symbol attribute. 242 | """ 243 | assert(self.symbol) 244 | if not self._symbol: 245 | # Retain original symbol in case rename is specified 246 | self._symbol = self.symbol 247 | 248 | # If we were @rename'd 249 | rename_tag: str|None = self.flags.get('rename') 250 | if rename_tag: 251 | if '.' in rename_tag: 252 | # Fully qualified name provided, take it as-is 253 | self.symbol = rename_tag 254 | else: 255 | # Non-qualified name provided, take it as relative to the current symbol 256 | self.symbol = ''.join(re.split(r'([.:])', self._symbol)[:-1]) + rename_tag 257 | 258 | 259 | def _set_name(self) -> None: 260 | """ 261 | Derives the fully qualified name for this reference based on the scope. Ref names 262 | necessarily *globally* unique, but they must be unique within a given top-level 263 | reference. This is because the main purpose of the name is to be used as link 264 | anchors within a given page, and each top-level ref gets its own page. 265 | """ 266 | self._apply_rename() 267 | self._name = self.symbol 268 | self._display = self.flags.get('display') or self.symbol 269 | 270 | 271 | def _set_topsym(self) -> None: 272 | """ 273 | Determines (and caches) the top-level symbol for this ref. 274 | 275 | Class and module refs are inherently top-level. Others, like fields, depend 276 | on their scope. For example the top-level symbol for a field of some class 277 | is the class name. 278 | """ 279 | # This is the default logic for non-top-level refs, which crawls the reference's 280 | # scopes upward until a TopRef instance is encountered. TopRef subclass 281 | # overrides. 282 | assert(self.scopes) 283 | for s in reversed(self.scopes): 284 | if isinstance(s, TopRef): 285 | self._topsym = s.name 286 | break 287 | else: 288 | log.error('%s:%s: could not determine which class or module %s belongs to', self.file, self.line, self.name) 289 | 290 | 291 | # 292 | # Typed References follow. Typed refs are cloned from untyped refs by the parser once the 293 | # type is known. 294 | # 295 | # Fields defined by typed refs are actually populated during the parser's process stage 296 | # (after all file parsing has completed, but before rendering). 297 | 298 | 299 | @dataclass 300 | class FieldRef(Reference): 301 | type: str = 'field' 302 | 303 | # User-defined meta value (parsed from @meta via flags) 304 | meta: Optional[str] = None 305 | # Renderable display name that takes tags such as @fullnames into account 306 | title: str = '' 307 | # Allowed types for this field, which can be empty if no @type tag 308 | types: List[str] = field(default_factory=list) 309 | 310 | def _set_name(self) -> None: 311 | """ 312 | Derive fully qualified field name (relative to topref). For class fields (i.e. 313 | attributes), these will be qualified based on the class name. 314 | """ 315 | # Field types must have a scope 316 | assert(self.scopes and self.scope) 317 | 318 | self._apply_rename() 319 | 320 | # Heuristic: if scope is a class and this field is under a static table, then 321 | # we consider it a metaclass static field and remove the 'static' part. 322 | if isinstance(self.scope, ClassRef) and '.static.' in self.symbol: 323 | self.symbol = self.symbol.replace('.static', '') 324 | 325 | # The display name for this ref, initialized to the @display tag value if provided 326 | display: str|None = self.flags.get('display') 327 | # For the ref name, start with the symbol for now. 328 | name: str = self.symbol 329 | 330 | # Determine if there is an explicit @scope for this reference or the collection we 331 | # belong to. 332 | scope_tag: str|None = self.flags.get('scope') 333 | if not scope_tag and self.collection: 334 | # No explicit scope defined on this ref, use the scope specified by the 335 | # collection we're contained within, if available. 336 | scope_tag = self.collection.flags.get('scope') 337 | if scope_tag: 338 | # @scope tag was given. Take the tail end of the symbol as we're going to 339 | # requalify it under the @scope value. 340 | symbol: str = re.split(r'[.:]', self.symbol)[-1] 341 | if scope_tag != '.': 342 | # Non-global scope. Determine what delimiter we should use based on the 343 | # original symbol. 344 | delim = ':' if ':' in self.symbol else '.' 345 | symbol = f'{scope_tag}{delim}{symbol}' 346 | self.symbol = symbol 347 | name = symbol 348 | display = display or symbol 349 | elif '.' not in self.symbol: 350 | # No @scope given, but we need to qualify the name based on the (unqualified) 351 | # symbol and scope. 352 | name = f'{self.scope.symbol}.{self.symbol}' 353 | display = display or name 354 | 355 | self._name = name.replace(':', '.') 356 | self._display = display or self.symbol 357 | 358 | 359 | # It's a bit dubious to subclass FieldRef here -- functions are obviously not fields -- 360 | # but in practice they are handled very similarly, so we're taking the easy way out on 361 | # this one. 362 | @dataclass 363 | class FunctionRef(FieldRef): 364 | type: str = 'function' 365 | 366 | # List of (name, types, docstring) 367 | params: List[Tuple[str, List[str], Content]] = field(default_factory=list) 368 | # List of (types, docstring)j 369 | returns: List[Tuple[List[str], Content]] = field(default_factory=list) 370 | 371 | 372 | @dataclass 373 | class CollectionRef(Reference): 374 | """ 375 | A collection can fields and functions, 376 | """ 377 | heading: str = '' 378 | # List of 'functions' and/or 'fields' to indicate which should be rendered in compact 379 | # form 380 | compact: List[str] = field(default_factory=list) 381 | functions: List['FunctionRef'] = field(default_factory=list) 382 | fields: List['FieldRef'] = field(default_factory=list) 383 | 384 | @dataclass 385 | class TopRef(CollectionRef): 386 | """ 387 | Represents a top-level reference such as class or module. 388 | """ 389 | # Ordered list of collections within this topref, which respects @within and @order 390 | collections: List[CollectionRef] = field(default_factory=list) 391 | 392 | def _set_topsym(self) -> None: 393 | # By default, the topref of a topref is itself 394 | self._topsym = self.name 395 | 396 | 397 | @dataclass 398 | class ManualRef(TopRef): 399 | type: str = 'manual' 400 | 401 | @dataclass 402 | class ModuleRef(TopRef): 403 | type: str = 'module' 404 | 405 | @dataclass 406 | class ClassRef(TopRef): 407 | type: str = 'class' 408 | 409 | @property 410 | def hierarchy(self) -> List['Reference']: 411 | clsrefs: list[Reference] = [self] 412 | while clsrefs[0].flags.get('inherits'): 413 | superclass = self.parser_refs.get(clsrefs[0].flags['inherits']) 414 | if not superclass: 415 | break 416 | else: 417 | clsrefs.insert(0, superclass) 418 | return clsrefs 419 | 420 | 421 | @dataclass 422 | class SectionRef(CollectionRef): 423 | type: str = 'section' 424 | # For sections within manuals, this is the heading level 425 | level: int = 0 426 | 427 | def _set_name(self) -> None: 428 | """ 429 | Fully qualified (relative to topref) name of the section. 430 | 431 | Manual sections are qualified based on the manual page name. @sections are *not* 432 | implicitly qualified, however, which means it's up to the user to ensure global 433 | uniqueness if cross-page section references are needed. 434 | """ 435 | if isinstance(self.scope, ManualRef): 436 | # We are a section within a manual 437 | self._name = '{}.{}'.format(self.scope.symbol, self.symbol) 438 | self._display = self.flags.get('display') or self.symbol 439 | else: 440 | super()._set_name() 441 | 442 | @dataclass 443 | class TableRef(CollectionRef): 444 | type: str = 'table' 445 | 446 | -------------------------------------------------------------------------------- /luadox/render/html.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021-2023 Jason Tackaberry 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | __all__ = ['HTMLRenderer'] 16 | 17 | import sys 18 | import os 19 | import re 20 | import mimetypes 21 | from contextlib import contextmanager 22 | from typing import Union, Tuple, List, Callable, Generator, Type, Optional 23 | 24 | import commonmark.blocks 25 | import commonmark_extensions.tables 26 | 27 | from ..assets import assets 28 | from ..log import log 29 | from ..reference import * 30 | from ..parse import * 31 | from ..utils import * 32 | from .base import Renderer 33 | 34 | # Files from the assets directory to be copied 35 | ASSETS = [ 36 | 'luadox.css', 37 | 'prism.css', 38 | 'prism.js', 39 | 'js-search.min.js', 40 | 'search.js', 41 | 'img/i-left.svg', 42 | 'img/i-right.svg', 43 | 'img/i-download.svg', 44 | 'img/i-github.svg', 45 | 'img/i-gitlab.svg', 46 | 'img/i-bitbucket.svg', 47 | ] 48 | 49 | # Effectively disable implicit code blocks 50 | commonmark.blocks.CODE_INDENT = 1000 51 | 52 | class CustomRendererWithTables(commonmark_extensions.tables.RendererWithTables): 53 | def __init__(self, renderer: 'HTMLRenderer', *args, **kwargs): 54 | self.renderer = renderer 55 | self.parser = renderer.parser 56 | super().__init__(*args, **kwargs) 57 | 58 | def make_table_node(self, _): 59 | return '' 60 | 61 | def link(self, node, entering): 62 | if node.destination.startswith('luadox:'): 63 | refid = node.destination[7:] 64 | # If this raises KeyError it indicates a bug in the parser code 65 | ref = self.parser.refs_by_id[refid] 66 | node.destination = self.renderer._get_ref_href(ref) 67 | super().link(node, entering) 68 | 69 | # https://github.com/GovReady/CommonMark-py-Extensions/issues/3#issuecomment-756499491 70 | # Thanks to hughdavenport 71 | class TableWaitingForBug3(commonmark_extensions.tables.Table): 72 | @staticmethod 73 | def continue_(parser, _=None): 74 | ln = parser.current_line 75 | if not parser.indented and commonmark.blocks.peek(ln, parser.next_nonspace) == "|": 76 | parser.advance_next_nonspace() 77 | parser.advance_offset(1, False) 78 | elif not parser.indented and commonmark.blocks.peek(ln, parser.next_nonspace) not in ("", ">", "`", None): 79 | pass 80 | else: 81 | return 1 82 | return 0 83 | commonmark.blocks.Table = TableWaitingForBug3 # pyright: ignore 84 | 85 | 86 | class HTMLRenderer(Renderer): 87 | def __init__(self, parser: Parser): 88 | super().__init__(parser) 89 | 90 | # Create a pseudo Reference for the search page using the special 'search' type. 91 | # This is used to ensure the relative paths are correct. Use the name '--search' 92 | # as this won't conflict with any user-provided names (because Lua comments begin 93 | # with '--'). 94 | ref = TopRef(parser.refs, file='search.html', symbol='--search') 95 | ref.flags['display'] = 'Search' 96 | parser.refs['--search'] = ref 97 | 98 | self._templates = { 99 | 'head': assets.get('head.tmpl.html').decode('utf8'), 100 | 'foot': assets.get('foot.tmpl.html').decode('utf8'), 101 | 'search': assets.get('search.tmpl.html').decode('utf8'), 102 | } 103 | self._assets_version = assets.hash()[:7] 104 | 105 | def _get_root_path(self) -> str: 106 | """ 107 | Returns the path prefix for the document root, which is relative to the 108 | current context. 109 | """ 110 | # The topref of the current context's reference. The path will be relative to 111 | # this topref's file. 112 | assert(self.ctx.ref) 113 | viatopref = self.ctx.ref.topref 114 | if (isinstance(viatopref, ManualRef) and viatopref.name == 'index') or \ 115 | viatopref.symbol == '--search': 116 | return '' 117 | else: 118 | return '../' 119 | 120 | def _get_ref_link_info(self, ref: Reference) -> Tuple[str, str]: 121 | """ 122 | Returns (html file name, URL fragment) of the given Reference object. 123 | """ 124 | # The top-level Reference object that holds this reference, which respects @within. 125 | topsym: str = ref.userdata.get('within_topsym') or ref.topsym 126 | try: 127 | topref = self.parser.refs[topsym] 128 | except KeyError: 129 | raise KeyError('top-level reference "%s" not found (from "%s")' % (topsym, ref.name)) from None 130 | 131 | prefix = self._get_root_path() 132 | if not isinstance(ref.topref, ManualRef) or ref.topref.name != 'index': 133 | prefix += '{}/'.format(topref.type) 134 | if isinstance(ref.topref, ManualRef) and ref.symbol: 135 | # Manuals don't use fully qualified fragments. 136 | fragment = '#' + ref.symbol if ref.scopes else '' 137 | else: 138 | fragment = '#{}'.format(ref.name) if ref.name != ref.topsym else '' 139 | return prefix + topsym + '.html', fragment 140 | 141 | def _get_ref_href(self, ref: Reference) -> str: 142 | """ 143 | Returns the href src for the given Reference object, which is directly used 144 | in tags in the rendered content. 145 | """ 146 | file, fragment = self._get_ref_link_info(ref) 147 | return file + fragment 148 | 149 | def _permalink(self, id: str) -> str: 150 | """ 151 | Returns the HTML for a permalink used for directly linkable references such 152 | as section headings, functions, fields, etc. 153 | """ 154 | return ''.format(id) 155 | 156 | def _markdown_to_html(self, md: str) -> str: 157 | """ 158 | Renders the given markdown as HTML and returns the result. 159 | """ 160 | parser = commonmark_extensions.tables.ParserWithTables() 161 | ast = parser.parse(md) 162 | return CustomRendererWithTables(self).render(ast) 163 | 164 | def _content_to_html(self, content: Content) -> str: 165 | output = [] 166 | for elem in content: 167 | if isinstance(elem, Markdown): 168 | output.append(self._markdown_to_html(elem.get())) 169 | elif isinstance(elem, Admonition): 170 | inner = self._content_to_html(elem.content) 171 | output.append(f'
{elem.title}
{inner.strip()}\n
') 172 | elif isinstance(elem, SeeAlso): 173 | refs = [self.parser.refs_by_id[id] for id in elem.refs] 174 | md = ', '.join(self.parser.render_ref_markdown(ref) for ref in refs) 175 | # HTML will have

tags so strip them out first. 176 | html = self._markdown_to_html(md).strip()[3:-4] 177 | output.append(f'
See also {html}
') 178 | else: 179 | raise ValueError(f'unsupported content fragment type {type(elem)}') 180 | return '\n'.join(output) 181 | 182 | def _markdown_to_text(self, md: str) -> str: 183 | """ 184 | Strips markdown codes from the given Markdown and returns the result. 185 | """ 186 | # Code blocks 187 | text = recache(r'```.*?```', re.S).sub('', md) 188 | # Inline preformatted code 189 | text = recache(r'`([^`]+)`').sub('\\1', text) 190 | # Headings 191 | text = recache(r'#+').sub('', text) 192 | # Bold 193 | text = recache(r'\*([^*]+)\*').sub('\\1', text) 194 | # Link or inline image 195 | text = recache(r'!?\[([^]]*)\]\([^)]+\)').sub('\\1', text) 196 | 197 | # Clean up non-markdown things. 198 | # Reference with custom display 199 | text = recache(r'@{[^|]+\|([^}]+)\}').sub('\\1', text) 200 | # Just a reference 201 | text = recache(r'@{([^}]+)\}').sub('\\1', text) 202 | # Consolidate multiple whitespaces 203 | text = recache(r'\s+').sub(' ', text) 204 | return text 205 | 206 | def _content_to_text(self, content: Content) -> str: 207 | """ 208 | Strips markdown codes from the given Content and returns the result. 209 | """ 210 | output = [] 211 | for elem in content: 212 | if isinstance(elem, Admonition): 213 | output.append(self._markdown_to_text(elem.title)) 214 | output.append(self._content_to_text(elem.content)) 215 | elif isinstance(elem, Markdown): 216 | output.append(self._markdown_to_text(elem.get())) 217 | return '\n'.join(output).strip() 218 | 219 | 220 | def _types_to_html(self, types: List[str]) -> str: 221 | """ 222 | Resolves references in the given list of types, and returns HTML of 223 | all types in a human-readable string. 224 | """ 225 | resolved: list[str] = [] 226 | for tp in types: 227 | ref = self.parser.resolve_ref(tp) 228 | if ref: 229 | href = self._get_ref_href(ref) 230 | tp = '{}'.format(href, tp) 231 | resolved.append('{}'.format(tp)) 232 | if len(resolved) <= 1: 233 | return ''.join(resolved) 234 | else: 235 | return ', '.join(resolved[:-1]) + ' or ' + resolved[-1] 236 | 237 | def _render_user_links(self, root: str, out: Callable[[str], None]) -> None: 238 | sections = sorted(s for s in self.config.sections() if s.startswith('link')) 239 | for section in sections: 240 | img = self.config.get(section, 'icon', fallback=None) 241 | cls = '' 242 | if img: 243 | if img in ('download', 'github', 'gitlab', 'bitbucket'): 244 | img = '{root}img/i-' + img + '.svg?' + self._assets_version 245 | img = ''.format(img.replace('{root}', root)) 246 | cls = ' iconleft' 247 | out(''.format( 248 | cls, 249 | self.config.get(section, 'url', fallback='').replace('{root}', root), 250 | self.config.get(section, 'tooltip', fallback=''), 251 | img or '', 252 | self.config.get(section, 'text'), 253 | )) 254 | 255 | @contextmanager 256 | def _render_html(self, topref: TopRef, lines: List[str]) -> Generator[ 257 | Callable[[str], None], 258 | None, 259 | None 260 | ]: 261 | """ 262 | A context manager that renders the page frame for the given topref, and 263 | yields a function that appends a line to the page within the inner 264 | content area. 265 | """ 266 | self.ctx.update(ref=topref) 267 | if not topref.collections and isinstance(topref, ManualRef): 268 | log.critical('manual "%s" has no sections (empty doc or possible symbol collision)', topref.name) 269 | sys.exit(1) 270 | 271 | fallback_title = self.config.get('project', 'name', fallback='Lua Project') 272 | project_title = self.config.get('project', 'title', fallback=fallback_title) 273 | if isinstance(topref, ManualRef): 274 | # For manual pages, the page title is the first section heading 275 | page_title = topref.collections[0].heading 276 | else: 277 | # For everything else, we use the ref's display name 278 | page_title = topref.display 279 | 280 | html_title = '{} - {}'.format(page_title, project_title) 281 | # Alias to improve readability 282 | out = lines.append 283 | root = self._get_root_path() 284 | head: list[str] = [] 285 | 286 | css = self.config.get('project', 'css', fallback=None) 287 | if css: 288 | # The stylesheet is always copied to doc root, so take only the filename 289 | _, css = os.path.split(css) 290 | head.append(''.format(root, css, self._assets_version)) 291 | 292 | favicon = self.config.get('project', 'favicon', fallback=None) 293 | if favicon: 294 | mimetype, _ = mimetypes.guess_type(favicon) 295 | mimetype = ' type="{}"'.format(mimetype) if mimetype else '' 296 | # Favicon is always copied to doc root, so take only the filename 297 | _, favicon = os.path.split(favicon) 298 | head.append(''.format(mimetype, root, favicon, self._assets_version)) 299 | 300 | out(self._templates['head'].format( 301 | version=self._assets_version, 302 | title=html_title, 303 | head='\n'.join(head), 304 | root=root, 305 | bodyclass='{}-{}'.format( 306 | # First segment of body class is the ref type, but for unknown refs (such 307 | # as Search page) fall back to 'other' 308 | topref.type or 'other', 309 | # Second segment is the stripped form of the ref name. 310 | recache(r'\W+').sub('', topref.name).lower() 311 | ) 312 | )) 313 | 314 | toprefs = self.parser.topsyms.values() 315 | manual = [ref for ref in toprefs if isinstance(ref, ManualRef)] 316 | classes = sorted([ref for ref in toprefs if isinstance(ref, ClassRef)], key=lambda ref: ref.name) 317 | modules = [ref for ref in toprefs if isinstance(ref, ModuleRef)] 318 | # Determine prev/next buttons relative to current topref. 319 | found = prevref = nextref = None 320 | for ref in manual + classes + modules: 321 | if found: 322 | nextref = ref 323 | break 324 | elif ref.topsym == topref.name or topref.symbol == '--search': 325 | found = True 326 | else: 327 | prevref = ref 328 | 329 | hometext = self.config.get('project', 'name', fallback=project_title) 330 | out('
') 331 | out('
') 332 | if self.config.has_section('manual') and self.config.get('manual', 'index', fallback=False): 333 | path = '' if (isinstance(topref, ManualRef) and topref.name == 'index') else '../' 334 | out(''.format(path, hometext)) 335 | else: 336 | out('
{}
'.format(hometext)) 337 | out('
') 338 | out('
') 339 | self._render_user_links(root, out) 340 | out('
') 341 | out('
') 342 | if prevref: 343 | out(''.format( 344 | self._get_ref_href(prevref), 345 | prevref.name, 346 | root, 347 | self._assets_version 348 | )) 349 | if nextref: 350 | out(''.format( 351 | self._get_ref_href(nextref), 352 | nextref.name, 353 | root, 354 | self._assets_version 355 | )) 356 | out('
') 357 | out('
') 358 | 359 | # Determine section headings to construct sidebar. 360 | out('') 420 | out('
') 421 | try: 422 | yield out 423 | finally: 424 | out('
') 425 | out(self._templates['foot'].format(root=root, version=self._assets_version)) 426 | 427 | def _render_topref(self, topref: TopRef) -> str: 428 | """ 429 | Renders a topref to HTML, returning a string containing the rendered HTML. 430 | """ 431 | lines = [] 432 | with self._render_html(topref, lines) as out: 433 | if isinstance(topref, (ClassRef, ModuleRef)): 434 | self._render_classmod(topref, out) 435 | elif isinstance(topref, ManualRef): 436 | self._render_manual(topref, out) 437 | return '\n'.join(lines) 438 | 439 | def _render_manual(self, topref: ManualRef, out: Callable[[str], None]) -> None: 440 | """ 441 | Renders the given manual top-level Reference as HTML, calling the given out() function 442 | for each line of HTML. 443 | """ 444 | out('
') 445 | if topref.content: 446 | # Preamble 447 | out(self._content_to_html(topref.content)) 448 | for secref in topref.collections: 449 | # Manual pages only contain SectionRefs 450 | assert(isinstance(secref, SectionRef)) 451 | out('{}'.format(secref.level, secref.symbol, secref.heading)) 452 | out(self._permalink(secref.symbol)) 453 | out(''.format(secref.level)) 454 | out(self._content_to_html(secref.content)) 455 | out('
') 456 | 457 | def _render_classmod(self, topref: Union[ClassRef, ModuleRef], out: Callable[[str], None]) -> None: 458 | """ 459 | Renders the given class or module top-level Reference as HTML, calling the given out() 460 | function for each line of HTML. 461 | """ 462 | assert(isinstance(topref, (ClassRef, ModuleRef))) 463 | for colref in topref.collections: 464 | self.ctx.update(ref=colref) 465 | 466 | # First collection within a class or module is the class/module itself. 467 | if isinstance(colref, TopRef): 468 | heading = '{} {}'.format(colref.type.title(), colref.heading) 469 | else: 470 | heading = self._markdown_to_html(colref.heading) 471 | 472 | out('
') 473 | out('

{}'.format( 474 | colref.type, 475 | colref.symbol, 476 | # Heading converted from markdown contains paragraph tags, and it 477 | # isn't valid HTML for headings to contain block elements. 478 | heading.replace('

', '').replace('

', '') 479 | )) 480 | out(self._permalink(colref.symbol)) 481 | out('

') 482 | out('
') 483 | if isinstance(colref, ClassRef): 484 | h = colref.hierarchy 485 | if len(h) > 1: 486 | out('
') 487 | out('
Class Hierarchy
') 488 | out('
    ') 489 | for n, cls in enumerate(h): 490 | if cls == colref: 491 | html = cls.name 492 | self_class = ' self' 493 | else: 494 | html = self._types_to_html([cls.name]) 495 | self_class = '' 496 | prefix = ((' '*(n-1)*6) + ' └─ ') if n > 0 else '' 497 | out('
  • {}{}
  • '.format(self_class, prefix, html)) 498 | out('
') 499 | out('
') 500 | 501 | if colref.content: 502 | out(self._content_to_html(colref.content)) 503 | 504 | fields_title = 'Fields' 505 | fields_meta_columns = 0 506 | fields_has_type_column = False 507 | for ref in colref.fields: 508 | n = 0 509 | if isinstance(ref.scope, ClassRef): 510 | fields_title = 'Attributes' 511 | if ref.meta: 512 | n += 1 513 | if ref.types: 514 | fields_has_type_column = True 515 | fields_meta_columns = max(n, fields_meta_columns) 516 | 517 | functions_title = 'Functions' 518 | functions_meta_columns = 0 519 | for ref in colref.functions: 520 | n = 0 521 | if isinstance(ref.scope, ClassRef) and ':' in ref.symbol: 522 | functions_title = 'Methods' 523 | if ref.flags.get('meta'): 524 | n += 1 525 | functions_meta_columns = max(n, functions_meta_columns) 526 | 527 | # 528 | # Output synopsis for this section. 529 | # 530 | fields_compact = 'fields' in colref.compact 531 | functions_compact = 'functions' in colref.compact 532 | if colref.functions or colref.fields: 533 | out('
') 534 | if not fields_compact: 535 | out('

Synopsis

') 536 | if colref.fields: 537 | if colref.functions or not fields_compact: 538 | out('
{}
'.format(fields_title)) 539 | out('
'.format('compact' if fields_compact else '')) 540 | for ref in colref.fields: 541 | out('') 542 | if not fields_compact: 543 | out(''.format(ref.name, ref.title)) 544 | else: 545 | link = self._permalink(ref.name) 546 | out(''.format(ref.name, ref.title, link)) 547 | nmeta = fields_meta_columns 548 | if ref.types: 549 | types = self._types_to_html(ref.types) 550 | out(''.format(types)) 551 | elif fields_has_type_column: 552 | out('') 553 | if ref.meta: 554 | html = self._markdown_to_html(ref.meta) 555 | out(''.format(html)) 556 | nmeta -= 1 557 | while nmeta > 0: 558 | out('') 559 | nmeta -= 1 560 | 561 | if not fields_compact: 562 | html = self._markdown_to_html(ref.content.get_first_sentence()) 563 | else: 564 | html = self._content_to_html(ref.content) 565 | if html: 566 | out(''.format(html)) 567 | out('') 568 | out('
{}{}{}{}{}{}
') 569 | 570 | if colref.functions: 571 | if colref.fields or not functions_compact: 572 | out('
{}
'.format(functions_title)) 573 | out(''.format('compact' if functions_compact else '')) 574 | for ref in colref.functions: 575 | out('') 576 | # For compact view, remove topsym prefix from symbol 577 | display = ref.display_compact if isinstance(ref.scope, ClassRef) else ref.title 578 | if not functions_compact: 579 | out(''.format(ref.name, display)) 580 | else: 581 | link = self._permalink(ref.name) 582 | params = ', '.join('{}'.format(param) for param, _, _ in ref.params) 583 | html = '' 584 | out(html.format(ref.name, display, params, link)) 585 | meta = functions_meta_columns 586 | if ref.meta: 587 | out(''.format(ref.meta)) 588 | meta -= 1 589 | while meta > 0: 590 | out('') 591 | meta -= 1 592 | 593 | if not functions_compact: 594 | html = self._markdown_to_html(ref.content.get_first_sentence()) 595 | else: 596 | html = self._content_to_html(ref.content) 597 | out(''.format(html)) 598 | out('') 599 | out('
{}(){}({}){}{}{}
') 600 | out('
') 601 | 602 | # 603 | # Output fields for this section 604 | # 605 | if colref.fields and not fields_compact: 606 | if colref.functions: 607 | out('

{}

'.format(fields_title)) 608 | out('
') 609 | for ref in colref.fields: 610 | out('
'.format(ref.name)) 611 | out('{}'.format(ref.display)) 612 | if ref.types: 613 | types = self._types_to_html(ref.types) 614 | out('{}'.format(types)) 615 | if ref.meta: 616 | out('{}'.format(ref.meta)) 617 | out(self._permalink(ref.name)) 618 | out('
') 619 | out('
') 620 | out(self._content_to_html(ref.content)) 621 | out('
') 622 | out('
') 623 | 624 | # 625 | # Output functions for this section 626 | # 627 | if colref.functions and not functions_compact: 628 | if colref.fields: 629 | out('

{}

'.format(functions_title)) 630 | out('
') 631 | for ref in colref.functions: 632 | params = ', '.join('{}'.format(param) for param, _, _ in ref.params) 633 | out('
'.format(ref.name)) 634 | out('{}({})'.format(ref.display, params)) 635 | if ref.meta: 636 | out('{}'.format(ref.meta)) 637 | out(self._permalink(ref.name)) 638 | out('
') 639 | out('
') 640 | out(self._content_to_html(ref.content)) 641 | # Only show the praameters table if there's at least one documented parameter. 642 | if any(types or doc for _, types, doc in ref.params): 643 | out('
Parameters
') 644 | out('') 645 | for (param, types, doc) in ref.params: 646 | out('') 647 | out(''.format(param)) 648 | out(''.format(self._types_to_html(types))) 649 | out(''.format(self._content_to_html(doc))) 650 | out('') 651 | out('
{}({}){}
') 652 | if ref.returns: 653 | out('
Return Values
') 654 | out('') 655 | for n, (types, doc) in enumerate(ref.returns, 1): 656 | out('') 657 | if len(ref.returns) > 1: 658 | out(''.format(n)) 659 | out(''.format(self._types_to_html(types))) 660 | out(''.format(self._content_to_html(doc))) 661 | out('') 662 | out('
{}.({}){}
') 663 | out('
') 664 | out('
') 665 | # Close inner section 666 | out('') 667 | # Close outer section 668 | out('') 669 | 670 | def render_search_index(self) -> str: 671 | log.info('generating search index') 672 | topref = self.parser.refs['--search'] 673 | self.ctx.update(ref=topref) 674 | lines = [] 675 | out = lines.append 676 | def add(ref: RefT, typ: Type[RefT]): 677 | href = self._get_ref_href(ref) 678 | text = self._content_to_text(ref.content) 679 | title = ref.display 680 | if typ == SectionRef and not isinstance(ref.topref, ManualRef): 681 | # Non-manual sections typically use the first sentence as the section 682 | # title. This heuristic uses the first sentence only if it's less than 80 683 | # characters, otherwise falls back to the section title. 684 | first, remaining = get_first_sentence(text) 685 | if len(first) < 80: 686 | title = first 687 | text = remaining 688 | text = text.replace('"', '\\"').replace('\n', ' ') 689 | title = title.replace('"', '\\"').replace('\n', ' ') 690 | if typ == ModuleRef: 691 | title = title.split('.', 1)[-1] 692 | out('{{path:"{}", type:"{}", title:"{}", text:"{}"}},'.format(href, typ.type, title, text)) 693 | 694 | out('var docs = [') 695 | for typ in ClassRef, ModuleRef, FieldRef, FunctionRef, SectionRef: 696 | for ref in self.parser.parsed[typ]: 697 | add(ref, typ) 698 | out('];') 699 | return '\n'.join(lines) 700 | 701 | def render_search_page(self) -> str: 702 | root = self._get_root_path() 703 | topref = self.parser.refs['--search'] 704 | assert(isinstance(topref, TopRef)) 705 | lines = [] 706 | with self._render_html(topref, lines) as out: 707 | out(self._templates['search'].format(root=root, version=self._assets_version)) 708 | return '\n'.join(lines) 709 | 710 | def render_landing_page(self) -> str: 711 | """ 712 | Returns rendered HTML for a landing page (index.html) which is used when there is 713 | no explicit manual page called "index" and which just returns a skeleton page with 714 | no body. 715 | """ 716 | # A bit lazy to reuse the search topref here, but we just need a reference 717 | # from the same directory so the link paths are correct. 718 | topref = self.parser.refs['--search'] 719 | assert(isinstance(topref, TopRef)) 720 | lines = [] 721 | with self._render_html(topref, lines): 722 | pass 723 | return '\n'.join(lines) 724 | 725 | 726 | def render(self, toprefs: List[TopRef], outdir: Optional[str]) -> None: 727 | """ 728 | Renders toprefs as HTML to the given output directory. 729 | """ 730 | if not outdir: 731 | log.warn('"out" is not defined in config file, assuming ./out/') 732 | outdir = 'out' 733 | os.makedirs(outdir, exist_ok=True) 734 | self.copy_file_from_config('project', 'css', outdir) 735 | self.copy_file_from_config('project', 'favicon', outdir) 736 | 737 | for ref in toprefs: 738 | if ref.userdata.get('empty') and ref.implicit: 739 | # Reference has no content and it was also implicitly generated, so we don't render it. 740 | log.info('not rendering empty %s %s', ref.type, ref.name) 741 | continue 742 | if isinstance(ref, ManualRef) and ref.name == 'index': 743 | typedir = outdir 744 | else: 745 | typedir = os.path.join(outdir, ref.type) 746 | os.makedirs(typedir, exist_ok=True) 747 | outfile = os.path.join(typedir, ref.name + '.html') 748 | log.info('rendering %s %s -> %s', ref.type, ref.name, outfile) 749 | html = self._render_topref(ref) 750 | with open(outfile, 'w', encoding='utf8') as f: 751 | f.write(html) 752 | 753 | js = self.render_search_index() 754 | with open(os.path.join(outdir, 'index.js'), 'w', encoding='utf8') as f: 755 | f.write(js) 756 | 757 | html = self.render_search_page() 758 | with open(os.path.join(outdir, 'search.html'), 'w', encoding='utf8') as f: 759 | f.write(html) 760 | 761 | if not self.parser.get_reference(ManualRef, 'index'): 762 | # The user hasn't specified an index manual page, so we generate a blank 763 | # landing page that at least presents the sidebar with available links. 764 | html = self.render_landing_page() 765 | with open(os.path.join(outdir, 'index.html'), 'w', encoding='utf8') as f: 766 | f.write(html) 767 | 768 | for name in ASSETS: 769 | outfile = os.path.join(outdir, name) 770 | if os.path.dirname(name): 771 | os.makedirs(os.path.dirname(outfile), exist_ok=True) 772 | with open(outfile, 'wb') as f: 773 | f.write(assets.get(name)) 774 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LuaDox - Lua Documentation Generator 2 | 3 | **👉 [Download the latest release](https://github.com/jtackaberry/luadox/releases/latest)** 4 | 5 | 📘 You can find an example of LuaDox's output **[here](https://reapertoolkit.dev)** 6 | 7 | LuaDox is: 8 | * born out of personal frustration with LDoc which repeatedly failed to work how I expected/wanted 9 | (which is perhaps more an indictment of me than of LDoc, as LuaDox is probably also accidentally 10 | opinionated about structure) 11 | * an attempt to make nice looking and searchable documentation generated from code 12 | * written in Python, strangely enough. Python 3.8 or later is required. 13 | * *not* strictly compatible with LuaDoc or LDoc tags and not a drop-in replacement, although obviously 14 | heavily influenced by them 15 | 16 | Markdown is used for styling, both in comments as well as standalone manual files, 17 | and `inline code` is implicitly resolved to linkable references (if such a reference 18 | exists). Standard markdown is supported, plus tables. 19 | 20 | A brief example using [middleclass](https://github.com/kikito/middleclass): 21 | 22 | ```lua 23 | --- Utility class to manipulate files. 24 | -- 25 | -- @class xyz.File 26 | -- @inherits xyz.Base 27 | xyz.File = class('xyz.File', xyz.Base) 28 | 29 | --- Seek constants. 30 | -- 31 | -- These constants can be used with `seek()`. 32 | -- 33 | -- @section seekconst 34 | -- @compact 35 | 36 | --- Seek from the beginning of the file. 37 | xyz.File.static.SEEK_SET = 'set' 38 | --- Seek from the current position. 39 | xyz.File.static.SEEK_CUR = 'cur' 40 | --- Seek to the end of the file. 41 | xyz.File.static.SEEK_END = 'end' 42 | 43 | --- Class API. 44 | --- @section api 45 | 46 | --- Opens a new file. 47 | -- 48 | -- @example 49 | -- f = xyz.File('/etc/passwd') 50 | -- f.seek(xyz.File.SEEK_END) 51 | -- 52 | -- @tparam string name the path to the file to open 53 | -- @tparam string|nil mode the access mode, where `r` is read-only and `w` is read-write. 54 | -- Nil assumes `r`. 55 | -- @treturn xyz.File a new file object 56 | -- @display xyz.File 57 | function xyz.File:initialize(name, mode) 58 | -- ... 59 | end 60 | 61 | --- Seeks within the file. 62 | -- 63 | -- @tparam seekconst|nil whence position to seek from, or nil to get current position 64 | -- @tparam number|nil offset the number of bytes relative to `whence` to seek 65 | -- @treturn number byte position within the file 66 | function xyz.File:seek(whence, offset) 67 | -- ... 68 | end 69 | ``` 70 | 71 | And the simplest possible usage: 72 | 73 | ```bash 74 | # Linux and OS X 75 | luadox file.lua 76 | 77 | # Windows 78 | python luadox file.lua 79 | ``` 80 | 81 | Which assumes a bunch of defaults, one of which is that the output directory `out/` is 82 | created with the rendered documentation. Obviously this and other customizations can 83 | be configured either by command line arguments and/or config file (see later). 84 | 85 | 86 | ## The Basics 87 | 88 | ### Documenting Elements 89 | 90 | LuaDox ignores standard Lua comments until a block of comments begins with three dashes, 91 | which is the marker that begins a **documentation block**: 92 | 93 | ```lua 94 | --- This begins a LuaDox documentation block. 95 | -- 96 | -- After this point, we can use double dashes. Anything that follows is 97 | -- considered part of the documentation up until the next non-comment 98 | -- line, which also includes blank lines, whereupon the block terminates. 99 | -- 100 | -- Here we declare this comment to be the preamble to a module page. 101 | -- 102 | -- @module mymod 103 | ``` 104 | 105 | The above example creates a new *element* (specifically a module element), which 106 | means it is a block of documentation that can be explicitly *referenced*. In this 107 | case, the reference name is `mymod`, which means elsewhere in documentation (whether 108 | in the same file or another file), this can be linked using one of 3 methods as 109 | shown below: 110 | 111 | ```lua 112 | --- Here begins another block of documentation. 113 | -- 114 | -- This one documents a function, because a function definition immediately follows 115 | -- the comment block. 116 | -- 117 | -- Also, we can link to @{mymod} like this, which converts to a hyperlink. Or you 118 | -- can control the link text @{mymod|so this text links to mymod}. It's also possible 119 | -- to use inline code markdown like this: `mymod`. 120 | function example() 121 | end 122 | ``` 123 | 124 | ### Collections 125 | 126 | `@module` (along with `@class`, `@section`, and `@table`) are special types of elements 127 | called *collections*. Functions and fields that have LuaDox comment marker (i.e. `---`) 128 | preceding their definitions belong to the most recently defined collection element (at 129 | least unless the `@within` tag is used to relocate it somewhere else). Collections show 130 | a summary table of all functions and methods, and then itemize each of them below the 131 | summary table in more detail. In the above example, the `example()` function would belong 132 | directly to the `mymod` collection. 133 | 134 | But it's also possible to explicitly create new sections, which are visually delineated 135 | in the rendered documentation: 136 | 137 | ```lua 138 | --- Special Functions. 139 | -- 140 | -- Here we create a new section because of the `@section` tag below. The first sentence 141 | -- of the comment block is the heading of the section, so it should be short and sweet, 142 | -- and it must end with a period (or some other sentence-ending punctuation like an 143 | -- exclamation point or question mark). 144 | -- 145 | -- Anything that follows is text that is included under the section heading. And of 146 | -- course *standard* **markdown** _is_ [supported](https://lua.org). 147 | -- 148 | -- @section specialfuncs 149 | 150 | --- Now we're about to document a function. The blank line just above is very important 151 | -- as it terminates the section block, and begins a new block, which will apply to 152 | -- the function below. 153 | -- 154 | -- Now this function will appear within the Special Functions section, because that 155 | -- was the most recent collection element defined. (It's possible to override which 156 | -- collection this function belongs to without changing the order in the code by 157 | -- using the @within tag.) 158 | function special() 159 | end 160 | ``` 161 | 162 | `@module` and `@class` are special types of collections called *top-level collections*. 163 | This means they are given their own separate pages in the documentation, and also all 164 | elements they contain will have their fully qualified names to be scoped under the 165 | top-level collection. 166 | 167 | For example, a field `somefield` in a `@module somemodule` will be fully qualified as 168 | `somemodule.somefield`, which is how it can be referenced from documentation outside the 169 | module. (`@section` is the exception here: section names are global, and it's up to 170 | you to make them globally unique if you want to be able to reference them from other 171 | pages in the documentation.) 172 | 173 | 174 | ### Functions/Methods 175 | 176 | While Lua itself doesn't have explicit classes, LuaDox formalizes terminology such that in 177 | `@class` collections, functions are titled as **methods**, while for `@module` or `@table` 178 | the term **function** is used. 179 | 180 | Comment blocks preceding function definitions will add a new function to the current 181 | collection, as seen in the earlier examples. However it's also possible to define a 182 | function as an assignment: 183 | 184 | ```lua 185 | --- This will be recognized as a function/method. 186 | xyz.some_function = function(a, b) 187 | -- ... 188 | end 189 | ``` 190 | 191 | ### Fields/Attributes 192 | 193 | Documentation preceding an assignment where the rvalue is not a function is treated as a field. 194 | In `@class` collections, fields are labeled as **attributes**. 195 | 196 | Fields can be defined anywhere in code: globally, within tables, within functions, etc. As long 197 | as there is a triple-dash documentation block that immediately precedes a non-function assignment, 198 | it will be added to the current collection as a field. 199 | 200 | ```lua 201 | --- This will be recognized as a field/attribute 202 | a = 42 203 | 204 | whatever = { 205 | --- This also works, but because "whatever" is not explicitly defined as a 206 | -- table using the @table tag, this value here is exactly equivalent to the 207 | -- above example. In fact, LuaDox will actually log a warning here because 208 | -- the lvalue "a" is redefined. 209 | a = 42 210 | } 211 | ``` 212 | 213 | A special case is also handled where the lvalue of the assignment is in the form `self.attr = x`, 214 | specifically when the lvalue is prefixed with `self.`. Normally the fully qualified lvalue is 215 | included in the documentation, but with `self.attr` the `self` is stripped off and the `attr` 216 | is registered directly within the scope of the current top-level container. 217 | 218 | Another special case specific to middleclass is in handling static fields. When an attribute 219 | defined in a `@class` collection contains the string `.static.` then it will be stripped out. 220 | 221 | The example below demonstrates both these special cases: 222 | 223 | ```lua 224 | --- This class does, well, something. 225 | -- @class xyz.Something 226 | -- @inherits xyz.Superclass 227 | xyz.Something = class('xyz.Superclass') 228 | 229 | --- Here the 'static' level will be automatically removed from the attribute name. 230 | xyz.Something.static.MYCONSTANT = 42 231 | 232 | function xyz.Something:initialize() 233 | xyz.Superclass.initialize(self) 234 | --- This is added as a field directly in the xyz.Something class. 235 | self.answer = 42 236 | end 237 | ``` 238 | 239 | Note that documentation comments must immediately *precede* field definitions and 240 | cannot be on the same line: 241 | 242 | ```lua 243 | --- Must precede the definition. 244 | foo = 'bar' 245 | 246 | -- Meanwhile ... 247 | foo = 'bar' --- This does NOT work. 248 | ``` 249 | 250 | ## Reference Resolution 251 | 252 | References that aren't fully qualified (such as `@{this}`) are resolved based on 253 | the scope where the reference was made. The resolution rules are: 254 | 1. Search fields or functions in the current collection 255 | 2. If the current collection is a `@section` or `@table`, search up the scope 256 | stack to the entire `@class` or `@module` 257 | 3. Treat the reference as fully qualified, and search the global space for that 258 | exact name 259 | 4. If the top-level collection containing the reference is a `@class`, then search up 260 | through the class hierarchy as established by `@inherits` 261 | 262 | When referencing a function, it's fine to include parens in the reference name. 263 | For example `@{foo()}` or even just markdown inline code `foo()`. 264 | 265 | 266 | ## Tags 267 | 268 | It's first important to underline that LuaDox is not LDoc. Many tags offered by LDoc are 269 | not supported, while many new tags are introduced to provide additional functionality. 270 | 271 | Moreover, tags that do intersect between LDoc and LuaDox are not always implemented with 272 | the same syntax or semantics, often because LuaDox extends their functionality. 273 | Consequently, you can expect a bit of a mess trying to pass LDoc-annotated code through 274 | LuaDox, especially when you've delicately structured your code so as to work around the 275 | many quirks of LDoc. 276 | 277 | Here is a summary of LuaDox tags, with more details below the table: 278 | 279 | | Tag | Type | Description | Example | 280 | |-|-|-|-| 281 | | `@module` | Top-level collection | Declares a module and sets the scope for future documented elements. Modules, like all top-level types, are given separate pages in the rendered documentation. | `@module utils` | 282 | | `@class` | Top-level collection | Like `@module` but for classes, which are also given their own separate documentation pages. See also `@inherits`. | `@class xyz.SomeClass` | 283 | | `@section` | Collection | Organizes documented elements such as fields, functions, and tables into a visually distinct group with a heading and arbitrary preamble. Sections can't be nested within other sections; a `@section` tag always creates a *new* section within a top-level collection. | `@section utils.files` | 284 | | `@table` | Nested collection | Declares a new collection containing only fields (not functions like other collections), and allows nesting where field names are fully qualified based on the encapsulating table(s). In most common cases, `@table` isn't needed and `@section` will suffice. | `@table constants` | 285 | | `@inherits` | `@class` modifier | Indicates that the current class is subclassed from another class. This influences how references are resolved (superclasses are searched) and the rendered class page includes a visual of the class hierarchy. | `@inherits xyz.BaseClass` | 286 | | `@tparam` | Function modifier | Documents a typed parameter of the function definition that follows | `@tparam number\|nil w the width of the image, or nil to derive it from height and aspect` | 287 | | `@treturn` | Function modifier | Documents a return value of the function definition that follows | `@treturn bool true if successful, false otherwise` | 288 | | `@see` | Section modifier | Adds a styled "See also" line linking to one or more space-delimited references | `@see ref1 ref2` | 289 | | `@type` | Field modifier | Documents the type of the field definition that follows | `@type table\|nil` | 290 | | `@meta` | Field modifier | Documents arbitrary information for the field definition that follows | `@meta read/write` | 291 | | `@within` | Function/field modifier | Relocates the field or function to another collection while preserving its name. | `@within someothermodule` | 292 | | `@order` | Element modifier | Normally elements are documented in the order they appear in source, but `@order` allows changing the position of an element relative to other elements in the same rendered page. | `@order before somefunc` | 293 | | `@compact` | Collection modifier | Normally, fields and functions in a collection are shown first in summary table form and then broken out later with full documentation. `@compact` controls whether fields and/or functions should *only* show in tabular form. Useful for elements with smaller comments, such as a table of constants. Without arguments, both functions and fields will be shown in compact form, but you can specify `fields` or `functions` as an argument to compact just one of them. | `@compact fields` | 294 | | `@fullnames` | Collection modifier | Normally the table summary of fields and functions are not fully qualified, they are the unqualified short names. This tag ensures the table summary shows the fully qualified name. Commonly combined with `@compact` | `@fullnames` | 295 | | `@display` | Element modifier | Explicitly overrides the display name of an element, but does not affect its name for reference purposes. | `@display MyClass` | 296 | | `@rename` | Element modifier | Overrides *both* the display name and actual name of the element, affecting both its presentation in rendered pages as well as how the element is referenced. | `@rename different_function` | 297 | | `@scope` | Element modifier | Changes the scope of non top-level elements (i.e. functions, fields, and tables, but not classes or modules), affecting both the element's display name and reference name. A special scope `.` can be used to treat the element as global and will prevent its name from being qualified by the collection it belongs to. Unlike `@within`, the element is still documented in the same place (class or module page), but its fully qualified name will reflect the given scope name. | `@scope .` | 298 | | `@alias` | Element modifier | Adds another name by which the element can be referenced. Does not affect the display name. | `@alias fooconsts` | 299 | | `@code` | Code block | Creates a code block with Lua syntax highlighting. Any contents indented below the `@code` line will be included in the code block. | (See below.) | 300 | | `@example` | Code block | Like `@code` but adds an "Example" heading just above the code block | (See `@code`) | 301 | | `@usage` | Code block | Like `@code` but adds an "Usage" heading just above the code block | (See `@code`) | 302 | | `@note` | Admonition block | Creates a visually distinct text block, useful to highlight notable information. Contents indented below the `@note` tag are included in the block. Can contain nested blocks, such as code blocks or other admonitions. | (See below.) | 303 | | `@warning` | Admonition block | Like `@note` but uses a red color | (See `@note`) 304 | | `@field` | Element | Declare a field within a collection without an explicit field assignment in Lua code. Rarely needed, and documenting field assignments is preferred and more flexible. Unlike LDoc, must *follow* `@table`. | `@field foo This is the description of the foo field` | 305 | | `@{name}` | Reference | Creates a link to the given element name, using `name` as the link text | `@{fileconsts}` | 306 | | `@{name\|display text}` | Reference | Creates a link to the given element name, but uses `display text` the link text | `@{fileconsts|file constants}` | 307 | 308 | ### `@module` 309 | 310 | Declares a module, which creates a separate page in the rendered documentation, and begins 311 | a new collections for all elements that follow. 312 | 313 | While uncommon, it's possible to have multiple `@module` tags in a single source file, 314 | which will result in multiple pages in the documentation. 315 | 316 | ```lua 317 | --- Common utility functions. 318 | -- 319 | -- @module utils 320 | ``` 321 | 322 | ### `@class` 323 | 324 | Declares a class, which, like `@module`, creates a separate page in the documentation, and 325 | is a collection for subsequent elements. 326 | 327 | See also `@inherits`. 328 | 329 | ```lua 330 | --- Class to manipulate images. 331 | -- 332 | -- @class xyz.Image 333 | ``` 334 | 335 | And also like `@module`, it's possible to have multiple `@class` tags in the same file. 336 | 337 | ### `@section` 338 | 339 | Creates a new section within a `@module` or `@class`. Sections are given their own 340 | visually distinct headings, and are collections for the fields and functions that follow. 341 | 342 | The first sentence (terminated with a period, exclamation point, or question mark) is 343 | used as the section heading. Anything past that is considered as section documentation 344 | below the heading. 345 | 346 | **The blank line(s) separating the `@section` block from the elements contained within the 347 | section is necessary.** This is how LuaDox knows where the documentation for the section 348 | ends and the documentation for a new element (such as a field or function) begins. 349 | 350 | ```lua 351 | --- Subclass API. 352 | -- 353 | -- These functions are not strictly part of the public API, but can be used to create 354 | -- custom subclasses. 355 | -- 356 | -- @section subclassapi 357 | 358 | --- Reset the state of the object. 359 | function xyz.Widget:_reset() 360 | -- ... 361 | end 362 | ``` 363 | 364 | 365 | ### `@table` 366 | 367 | Creates a new table collection, which is similar to `@section` but differs in three ways: 368 | 1. Nested `@table` are supported, where fully qualified field names are based on the 369 | full scope of all containing tables (e.g. `foo.bar.baz.field` where `foo`, `bar`, and 370 | `baz` are nested tables). 371 | 2. Only fields are shown. Functions are rendered in documentation as any other field. 372 | 3. Unlike `@section`, a blank line isn't needed between the preamble documentation and 373 | fields, because LuaDox knows to terminate the preamble as soon as the table 374 | declaration begins. 375 | 376 | ```lua 377 | -- xyz.os. 378 | -- 379 | -- These fields are available immediately upon loading the `xyz` module. 380 | -- 381 | -- @table xyz.os 382 | -- @compact 383 | xyz.os = { 384 | --- true if running on Mac OS X, false otherwise 385 | mac = (_os == 'osx'), 386 | --- true if running on Windows, false otherwise 387 | windows = (_os == 'win'), 388 | --- true if running on Linux, false otherwise 389 | linux = (_os == 'lin' or _os == 'oth'), 390 | } 391 | ``` 392 | 393 | ### `@inherits` 394 | 395 | Used within the context of a `@class` block to declare that the class has been derived 396 | from some other class. The rendered HTML for the class page will include a tree showing 397 | the full class hierarchy. 398 | 399 | The `@inherits` tag takes a single argument that is the name of the immediate superclass. 400 | 401 | 402 | ```lua 403 | --- @class xyz.Subclass 404 | -- @inherits xyz.BaseClass 405 | ``` 406 | 407 | Unqualified references made within the class documentation (all sections, fields, 408 | functions etc. for that class) will search for the name up the class's hierarchy. 409 | If a name is defined in both the current class and one of the superclasses, the 410 | unqualified name will refer to the current class, and a fully qualified name must 411 | be used to link to the superclass's field/function. 412 | 413 | 414 | ### `@tparam` 415 | 416 | Defines a typed parameter for the function immediately following the comment block. The 417 | format of this tag is `@tparam ` where: 418 | * `` is a pipe (`|`) delimited list of possible types, where the type name is 419 | resolved to a link if possible 420 | * `` is the name of the parameter from the function signature 421 | * `` is everything that follows, and which can wrap on multiple lines 422 | where subsequent lines are indented 423 | 424 | ```lua 425 | --- Clears the window to a specific color. 426 | -- 427 | -- @tparam colortype|string|nil color the color to paint the window 428 | -- background, where nil is black 429 | function clear(color) 430 | -- ... 431 | end 432 | ``` 433 | 434 | Type names can refer to section names as well, which is a convenient way to document 435 | custom complex types such as constants and enums (or Lua approximations thereof). See the 436 | `seekconst` type from the example at the top of this page. 437 | 438 | 439 | ### `@treturn` 440 | 441 | Defines a return value for the function immediately following the comment block. The 442 | format of this tag is `@treturn ` where `` and `` 443 | are the same as that described for `@tparam`. 444 | 445 | Multiple `@treturn` tags can be used for functions that return multiple values. 446 | 447 | ```lua 448 | --- Return the contents of the clipboard. 449 | -- 450 | -- @treturn string|nil the clipboard contents, or nil if system clipboard 451 | -- is not available. 452 | -- @treturn string|nil the mimetype of the clipboard contents, or nil if 453 | -- clipboard not available. 454 | function get_clipboard() 455 | -- ... 456 | end 457 | ``` 458 | 459 | ### `@see` 460 | 461 | Displays a "See also" line that links to one or more references. The tag takes multiple reference 462 | names separated by one space. Function references can optionally include parens, but will always 463 | be displayed with them. 464 | 465 | ```lua 466 | --- @see xyz.Clipboard:get() get_clipboard() xyz.SomeClass 467 | ``` 468 | 469 | This is slightly different from simply writing the line like "See also `get_clipboard()`" 470 | as it is wrapped in a div with class `see` that can be customized in CSS. 471 | 472 | ### `@type` 473 | 474 | Used in the comment block preceding a field definition and defines the field's type. This 475 | tag takes the form `@type ` and, like `@tparam` and `@treturn`, the types argument 476 | is a pipe (`|`) delimited list of possible types, which will be resolved into links if 477 | possible. 478 | 479 | Field types are shown both in the summary table as well as the full detailed field list. 480 | 481 | ```lua 482 | --- If true, scrolling smoothly animates, while false scrolls in steps. Nil will use the 483 | -- global default 484 | -- @type bool|nil 485 | smooth_scroll = nil 486 | ``` 487 | 488 | ### `@meta` 489 | 490 | Like `@type`, this is used in comment blocks preceding field definitions and can be used 491 | to communicate any arbitrary custom thing, however unlike `@type` it also works for 492 | functions. This tag takes the form `@meta ` where `` is a string that 493 | is allowed to contain spaces. 494 | 495 | The meta value is displayed alongside any defined types via `@type` in both the summary 496 | table as well as the detailed field list. 497 | 498 | A useful application of `@meta` is to indicate whether the field/attribute in question is 499 | considered read-only or read/write as far as the API caller is concerned. 500 | 501 | ```lua 502 | -- The "defaults" table isn't meaningful here as far as documentation is concerned. 503 | -- This is just a regular comment, not a triple-dash documentation block, so LuaDox 504 | -- ignores it. Fields defined and documented inside this table are added directly 505 | -- to the current collection. 506 | defaults = { 507 | --- The current width of the window which is updated when the user resizes the window. 508 | -- @type number 509 | -- @meta read-only 510 | w = 640 511 | } 512 | ``` 513 | 514 | ### `@within` 515 | 516 | Can be included in a documentation block preceding a function or field definition to relocate 517 | it to some other collection, while preserving the original name for display purposes. The tag 518 | takes the form `@within ` where `` is the name of any collection, such as a 519 | `@class`, `@module`, or -- more usefully -- a `@section`, either in the same class or module, 520 | or some other. 521 | 522 | This can be used to affect the location of a field or function in documentation without needing 523 | to reorder your code. If you want exact control of the location relative to other fields or 524 | functions in that collection, you can use `@order`. 525 | 526 | ```lua 527 | --- Called when the widget's position needs to be recalculated. 528 | -- @within subclassapi 529 | function xyz.Widget:_layout() 530 | end 531 | 532 | -- ... other stuff ... 533 | 534 | --- API available to subclasses. 535 | -- @section subclassapi 536 | ``` 537 | 538 | This is a bit of a rare case, but if the collection being targeted by `@within` itself has a 539 | `@rename` tag, the collection name that `@within` needs to reference is the pre-renamed 540 | name of the target collection. 541 | 542 | ### `@order` 543 | 544 | Affects where the element (whether field, function, table, or section) appears in the rendered 545 | documentation. This isn't used to move a field or function to another collection -- use 546 | `@within` for that -- but it changes the location of the element relative to its siblings in 547 | the collection. 548 | 549 | Non top-level collections (i.e. `@section` and `@table`) can also be reordered relative to one 550 | another in the same module or class. 551 | 552 | The tag takes the form `@order []` where `` is one of: 553 | * `before`: moves the element *before* the given (fully qualified) anchor element 554 | * `after`: moves the element *after* the given (fully qualified) anchor element 555 | * `first`: make the element the first one in the collection (and where `` is not needed) 556 | * `last`: makes the element the last one in the collection (and where `` is not needed) 557 | 558 | ```lua 559 | --- Draws the widget. 560 | -- @within subclassapi 561 | -- @order after xyz.Widget:_layout 562 | function xyz.Widget:_draw() 563 | end 564 | ``` 565 | 566 | ### `@compact` 567 | 568 | Collections include a summary table of fields and functions within the collection, where 569 | each element includes only the first sentence from their documentation, before enumerating 570 | the full list of elements with their full documentation blocks below the summary table. 571 | 572 | The `@compact` tag is used to skip the more detailed list, showing only the tabular form. 573 | In this case, the full documentation is included in the table, not just the first sentence. 574 | 575 | The tag takes an optional argument, either `field` or `function` that skips the full detailed 576 | list for one or the other type of element. If the argument is omitted, both fields and 577 | functions are shown only in tabular form. 578 | 579 | See `@fullnames` below for a combined example. 580 | 581 | ### `@fullnames` 582 | 583 | Normally a collection's table summary of fields and functions displays the unqualified 584 | short name. This tag, which takes no arguments, causes the table view to display the fully 585 | qualified name instead. 586 | 587 | ```lua 588 | --- Seek constants. 589 | -- 590 | -- These constants can be used with `seek()`. 591 | -- 592 | -- @section seekconst 593 | -- @compact 594 | -- @fullnames 595 | 596 | --- Seek from the beginning of the file. 597 | xyz.File.static.SEEK_SET = 'set' 598 | --- Seek from the current position. 599 | xyz.File.static.SEEK_CUR = 'cur' 600 | --- Seek to the end of the file. 601 | xyz.File.static.SEEK_END = 'end' 602 | ``` 603 | 604 | ### `@display` 605 | 606 | Affects how the element is displayed in documentation, but doesn't alter how the element 607 | is referenced. This tag takes the form `@display ` where `` is the overridden 608 | display name. 609 | 610 | One use case is to change the name of middleclass initializers, where the class is invoked 611 | directly to construct a new instance: 612 | 613 | ```lua 614 | --- Creates a new widget with the given attributes. 615 | -- @display xyz.Widget 616 | function xyz.Widget:initialize(attrs) 617 | ``` 618 | 619 | ### `@rename` 620 | 621 | Like `@display` in that it changes the element's display name in documentation, but *also* changes 622 | the name for references. 623 | 624 | ### `@scope` 625 | 626 | Element names are normally qualified based on their containing class, module, or table. 627 | For example, a field `bar` defined in a `@class Foo` would be fully qualified as 628 | `Foo.bar`. However, the `@scope` tag can override the containing scope -- `Foo` in this 629 | case -- with any arbitrary symbol. This affects how the element is both displayed and how 630 | it's referenced, however doesn't change which collection the element appears in. (Use 631 | `@within` for that.) 632 | 633 | The tag takes the form `@scope ` where `` replaces the element's normal scope name. 634 | A special scope `.` (single dot) will treat the element as global, preventing it from being 635 | qualified by anything: the field or function will be considered as global both in how it's 636 | displayed and referenced. 637 | 638 | ```lua 639 | --- Miscellaneous utilities. 640 | -- @module utils 641 | 642 | --- Normally this field would be qualified as utils.MYCONST, but this makes it appear 643 | -- as a global value, and can be referenced elsewhere as @{MYCONST} 644 | -- @scope . 645 | MYCONST = 42 646 | ``` 647 | 648 | ### `@alias` 649 | 650 | Adds another name by which the element can be referenced elsewhere in documentation. The 651 | display name is unchanged, and the element's normal name can still be used for references. 652 | This merely adds an additional name for references. 653 | 654 | 655 | ### `@code` 656 | 657 | Renders a fenced code block with syntax highlighting in the documentation. The tag takes 658 | an optional argument that dictates the syntax highlighting language, which defaults to 659 | `lua` when not specified. 660 | 661 | Any commented lines indented within @code are included in the markdown code block. The 662 | code block terminates as soon as a line has less indentation than the first line under the 663 | `@code` tag. 664 | 665 | ```lua 666 | --- Some function to do a thing. 667 | -- 668 | -- This might be some example usage: 669 | -- @code 670 | -- -- This is actually a comment in the code block. 671 | -- -- Subsequent lines indented at this level are included in the block. 672 | -- local x = do_a_thing() 673 | -- 674 | -- Now that this line is indented less than the first line under @code, this will 675 | -- *not* be included in the code block, but will start a new paragraph underneath 676 | -- it. The blank line separating this paragraph and the code block isn't significant, 677 | -- only the indentation level matters. 678 | function do_a_thing() 679 | -- ... 680 | end 681 | ``` 682 | 683 | ### `@example` 684 | 685 | Like `@code`, and works exactly the same way in terms of the semantics of indendation, but adds a 686 | heading Example" above the syntax-highlighted code block. 687 | 688 | ### `@usage` 689 | 690 | Like `@example`, but the heading says "Usage" instead. 691 | 692 | ### `@note` 693 | 694 | Creates a visually distinct paragraph (bordered with a green background color), which can 695 | be used to emphasize noteworthy content. 696 | 697 | This tag takes the form `@note ` where `<title>` is an *optional* arbitrary string 698 | (which can include spaces) that acts as the title of the block. Indentation controls the 699 | contents of the block, exactly as `@code` works. 700 | 701 | Nesting is possible, including (and most usefully) `@code` blocks which can appear 702 | within admonitions. 703 | 704 | 705 | ```lua 706 | --- This is the start of a normal documentation block. 707 | -- 708 | -- Some standard documentation content would go here. 709 | -- 710 | -- @note This is the title of the block 711 | -- Now anything indented at this level is included within the admonition paragraph. 712 | -- That includes this line, but not the next one. 713 | -- 714 | -- @code 715 | -- -- This is a nested code block inside the note. 716 | -- foo() 717 | -- 718 | -- This line is dedented relative to the first line under @note so it starts a normal 719 | -- paragraph at the same level as the first one. 720 | ``` 721 | 722 | ### `@warning` 723 | 724 | Exactly like `@note` but uses a red background instead of green so is useful for warning 725 | or cautionary content. 726 | 727 | ### `@field` 728 | 729 | Adds a field to the current collection purely a comment, without the need for a line of 730 | Lua code to declare and assign the field. 731 | 732 | This tag takes the form `@field <name> <description>` where `<name>` is the name of the field 733 | and `<description>` is an arbitrary, single line description of the field. 734 | 735 | ```lua 736 | --- @field level The current log level. 737 | ``` 738 | 739 | The above is semantically equivalent to this: 740 | 741 | ```lua 742 | --- The current log level. 743 | level = nil 744 | ``` 745 | 746 | Generally the second form above is preferred, because it allows for multiple lines and 747 | even paragraphs of comments, as well as field modifiers such as `@type` and `@meta`. 748 | 749 | Unlike LDoc, `@field` must follow a `@table` definition: 750 | 751 | ```lua 752 | --- Current mouse state. 753 | -- @table mouse 754 | -- @field x the x coordinate of the cursor 755 | -- @field y the y coordinate of the cursor 756 | -- @field button the current mouse button pressed 757 | ``` 758 | 759 | ### Reference tags 760 | 761 | Reference tags are used to create hyperlinks in the rendered documentation to any element 762 | in any file. Reference tags can take either of these forms: 763 | 1. `@{name}`: resolve `name` per the reference resolution rules described earlier, and use 764 | the fully qualified form of the reference name as the link text (even if `name` itself 765 | is not fully qualified) 766 | 2. `@{name|link text}`: resolve `name` but use the given `link text` instead of the 767 | fully qualified name of the reference. 768 | 769 | Although not a tag, if the contents of markdown `inline code` is a resolvable name, it 770 | will be rendered as a hyperlink (still with preformatted text), but unlike `@{name}` which 771 | uses the fully qualified form, the with `inline code` the hyperlink text will be as 772 | written. 773 | 774 | ## Manual Pages 775 | 776 | Arbitrarily many separate custom markdown files can be included in the rendered 777 | documentation. They are defined in the `[manual]` section of the config file, or can be 778 | passed using the `-m` or `--manual` command line argument. 779 | 780 | Each document is defined in the form `id=filename.md` where `id` is the top-level scope 781 | name for reference purposes (see later), and also dictates the name of the rendered html 782 | file. 783 | 784 | Consider this configuration, for example: 785 | 786 | ``` 787 | [manual] 788 | index=intro.md 789 | tutorial=tut.md 790 | ``` 791 | 792 | This will add both pages to the manual. **index** is a special id, which is written 793 | as the root `index.html` in the rendered documentation, and is also linked from the 794 | topbar on every page. 795 | 796 | Suppose our `intro.md` looked like: 797 | 798 | ```markdown 799 | # Introduction 800 | 801 | Some introductory paragraph. By the way, images are supported: 802 | 803 | ![](img/foo.png) 804 | 805 | The image is relative to the path of the current file. It's up to you to 806 | copy the `img/` directory to the rendered documentation output directory 807 | after. 808 | 809 | ## How to install 810 | 811 | This is a preamble paragraph on installation. 812 | 813 | ### Linux 814 | 815 | How to install on Linux ... 816 | 817 | ### OS X 818 | 819 | How to install on a Mac ... 820 | 821 | ### Windows 822 | 823 | Sorry about your luck ... 824 | 825 | #### This is a level 4 heading 826 | 827 | Nothing very interesting here. 828 | ``` 829 | 830 | Within the markdown, the level 1 heading dictates the title of the manual page, which 831 | is used in the Manual section of the sidebar, as well as the HTML title for the manual 832 | page. In the above example, that's "Introduction". 833 | 834 | Level 2 and level 3 headings are included in the table of contents in the sidebar. 835 | 836 | ### References and Manual Pages 837 | 838 | The manual page id (e.g. `index` and `tutorial` in the example above) is the top-level 839 | symbol. You will want to make sure you pick an id that doesn't conflict with any 840 | `@module` or `@class` name from the documentation, as these all share the top-level 841 | namespace. 842 | 843 | Level 1, 2, and 3 headings are names subordinate to the id, and are converted to slugs by 844 | converting everything to lowercase, removing all punctuation, and replacing spaces with 845 | underscores. 846 | 847 | For example, `index.how_to_install` or `index.linux`. This name can be referenced from 848 | code, and also other manual pages. The `@{name}` and `@{name|link text}` reference tags 849 | are supported in manual pages as well. 850 | 851 | 852 | ## Execution 853 | 854 | LuaDox is distributed as a single binary that can be downloaded [on the release 855 | page](https://github.com/jtackaberry/luadox/releases/latest). On Linux and OS X, the 856 | binary can be executed directly: 857 | 858 | ```bash 859 | $ luadox -c luadox.conf 860 | ``` 861 | 862 | But on Windows, Python must be called directly (and of course this also works on 863 | Linux and OS X): 864 | 865 | ``` 866 | C:\src\luadox> python luadox -c luadox.conf 867 | ``` 868 | 869 | `luadox --help` will output usage instructions: 870 | 871 | ``` 872 | usage: luadox [-h] [-c FILE] [-n NAME] [-o DIRNAME] [-m [ID=FILENAME [ID=FILENAME ...]]] 873 | [--css FILE] [--favicon FILE] [--nofollow] [--encoding CODEC] [--version] 874 | [FILE [FILE ...]] 875 | 876 | positional arguments: 877 | [MODNAME=]FILE List of files to parse or directories to crawl 878 | with optional module name alias 879 | 880 | optional arguments: 881 | -h, --help show this help message and exit 882 | -c FILE, --config FILE 883 | Luadox configuration file 884 | -n NAME, --name NAME Project name (default Lua Project) 885 | -o DIRNAME, --outdir DIRNAME 886 | Directory name for rendered files, created if necessary (default ./out) 887 | -m [ID=FILENAME [ID=FILENAME ...]], --manual [ID=FILENAME [ID=FILENAME ...]] 888 | Add manual page in the form id=filename.md 889 | --css FILE Custom CSS file 890 | --favicon FILE Path to favicon file 891 | --nofollow Disable following of require()'d files (default false) 892 | --encoding CODEC Character set codec for input (default UTF-8) 893 | --version show program's version number and exit 894 | ``` 895 | 896 | The positional `[MODNAME=]FILE` argument(s) defines what source files to scan. The 897 | `FILE` part can be either specific Lua source files, or directories within which 898 | `init.lua` exists. By default, LuaDox will follow and parse all files that are 899 | `require()`d within the code, provided the required file is discovered within any of the 900 | directories containing the files passed on the command line. 901 | 902 | The optional `MODNAME` part of the argument explicitly specifies the Lua module name as 903 | `require()`d in code. For example, if your library is called `foo` and your source files 904 | are held in `../src/foo` then LuaDox knows that when requiring `foo.bar.baz` from Lua, we 905 | should check `../src/foo/bar/baz.lua` because of the matching `foo` component between the 906 | module name and the path. 907 | 908 | However, if all your source files for module `foo` were instead contained in `../src`, 909 | say, you need to tell LuaDox that requiring `foo.bar` is actually at `../src/bar.lua`. 910 | This is done by specifying `MODNAME` in the argument, i.e. `foo=../src`. 911 | 912 | Bottom line: if your directory structure is directly named after the module name, you 913 | probably don't need to specify the `MODNAME` alias, but if your directory is called 914 | something else, like `src`, you do. 915 | 916 | The behavior to automatically discover and parse `require()`d files can be disabled with 917 | the `--nofollow` argment or setting `follow = false` in the config file, in which case 918 | LuaDox will only parse files explicitly passed. 919 | 920 | Most options can be defined on the command line, but it may be more convenient to 921 | use a config file. 922 | 923 | Config files are ini-style files that define these sections: 924 | * `[project]` for project level settings 925 | * `[manual]` for manual pages where each page is a separate `id=filename` line 926 | * `[link<n>]` for user-defined custom links that appear on the center of 927 | each page, and where `<n>` is a number that controls the order. 928 | 929 | Here's an annotated example `luadox.conf` that describes the available config 930 | properties. All properties are optional except for files (although files could 931 | also be passed on the command line if you prefer). 932 | 933 | ```ini 934 | [project] 935 | # Project name that is displayed on the top bar of each page 936 | name = My Lua Project | Where Awesome Things Happen 937 | # HTML title that is appended to every page. If not defined, name is used. 938 | title = My Lua Project 939 | # A list of files or directories for LuaDox to parse. Globs are supported. 940 | # This can be spread across multiple lines if you want, as long as the 941 | # other lines are indented. 942 | files = ../app/rtk/widget.lua ../app/rtk/ 943 | # The directory containing the rendered output files, which will be created 944 | # if necessary. 945 | outdir = html 946 | # Path to a custom css file that will be included on every page. This will 947 | # be copied into the outdir. 948 | css = custom.css 949 | # Path to a custom favicon. This will be copied into the outdir. 950 | favicon = img/favicon.png 951 | # If require()d files discovered in source should also be parsed. 952 | follow = true 953 | # Character encoding for input files, which defaults to the current system 954 | # locale. Output files are always utf8. 955 | encoding = utf8 956 | 957 | [manual] 958 | # Custom manual pages in the form: id = filename. 959 | # 960 | # The ids must not conflict with any class or module name otherwise references 961 | # will not properly resolve. 962 | index = intro.md 963 | tutorial = tut.md 964 | 965 | [link1] 966 | icon = download 967 | text = Download 968 | url = {root}index.html#download 969 | 970 | [link2] 971 | icon = github 972 | text = GitHub 973 | url = https://github.com/me/myproject 974 | ``` 975 | 976 | Link sections are optional. Each section takes these options: 977 | * `text` (required): the link's text 978 | * `url` (required): 979 | * `icon` (optional): the name of a built-in icon, or path to a custom image file. 980 | Currently supported built-in icon names are `download`, `github`, `gitlab`, and 981 | `bitbucket`. If the value isn't one of the built-in names then it's treated as 982 | a path, where `{root}` will be replaced with the relative path to the document 983 | root. 984 | * `tooltip` (optional): the tooltip text that appears when the mouse hovers over 985 | the hyperlink. 986 | 987 | User-defined links currently can't be specified on the command line, they must 988 | be defined in the config file. 989 | 990 | ## Docker Image 991 | 992 | LuaDox is also available as a [Docker image on Docker Hub](https://hub.docker.com/r/jtackaberry/luadox): 993 | 994 | ```bash 995 | $ docker run -v ~/src/myproject:/project -w /project/doc jtackaberry/luadox luadox -c luadox.conf 996 | ``` 997 | 998 | Of course, that's a bit cumbersome, having to set up the volume mount and 999 | working directory, so for command line use the release binary is probably more 1000 | convenient. However the Docker image can be useful when generating 1001 | documentation as part of a CI/CD pipeline, such as GitHub Actions. 1002 | --------------------------------------------------------------------------------