diff --git a/.github/workflows/webviz-config.yml b/.github/workflows/webviz-config.yml index fc4ee34a..fd85563d 100644 --- a/.github/workflows/webviz-config.yml +++ b/.github/workflows/webviz-config.yml @@ -36,6 +36,11 @@ jobs: with: python-version: ${{ matrix.python-version }} + - name: 📦 Install npm dependencies + run: | + npm ci --ignore-scripts + npm run postinstall + - name: 📦 Install webviz-config with dependencies run: | pip install 'pandas==${{ matrix.pandas-version }}' @@ -65,9 +70,7 @@ jobs: webviz certificate webviz preferences --theme default pytest ./tests --headless --forked - pushd ./docs - python build_docs.py - popd + webviz docs --portable ./docs_build --skip-open - name: 🚢 Build and deploy Python package if: github.event_name == 'release' && matrix.python-version == '3.6' && matrix.pandas-version == '1.*' @@ -75,6 +78,7 @@ jobs: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.pypi_webviz_token }} run: | + export SETUPTOOLS_SCM_PRETEND_VERSION=${GITHUB_REF//refs\/tags\//} python -m pip install --upgrade setuptools wheel twine python setup.py sdist bdist_wheel twine upload dist/* @@ -82,7 +86,7 @@ jobs: - name: 📚 Update GitHub pages if: github.event_name != 'schedule' && github.ref == 'refs/heads/master' && matrix.python-version == '3.6' && matrix.pandas-version == '1.*' run: | - cp -R ./docs/_build ../_build + cp -R ./docs_build ../docs_build git config --local user.email "webviz-github-action" git config --local user.name "webviz-github-action" @@ -91,7 +95,7 @@ jobs: git clean -f -f -d -x git rm -r * - cp -R ../_build/* . + cp -R ../docs_build/* . git add . diff --git a/.gitignore b/.gitignore index dfd95b6c..3b8b674e 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,9 @@ venv .DS_Store dist build +webviz_config/_docs/static/fonts +webviz_config/_docs/static/INTRODUCTION.md +webviz_config/_docs/static/*.js +webviz_config/_docs/static/*.css +!webviz_config/_docs/static/webviz-doc.js +!webviz_config/_docs/static/webviz-doc.css diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5fc0389b..e12beca0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -509,15 +509,55 @@ black --check webviz_config tests ## Build documentation -End-user documentation (i.e. YAML configuration file) be created -after installation by +`webviz-config` can automatically build documentation for all installed plugins. E.g. +the end user can get an overview of all installed plugins, and their arguments, by +running +```bash +webviz docs +``` +in the terminal. Behind the scenes, `webviz-config` will then create a +[`docsify`](https://github.com/docsifyjs/docsify) setup and open it `localhost`. +The setup can also be deployed to e.g. GitHub Pages directly. To store the documentation +output run ```bash -pip install .[tests] # if not already done -cd ./docs -python ./build_docs.py +webviz docs --portable ./built_docs --skip-open +``` +The `--skip-open` argument is useful in a CI/CD setting, to prevent `webviz-config` +from automatically trying to open the created documentation in the browser. + +### Improve plugin documentation + +Auto-built `webviz` documentation will: +- Find all installed plugins. +- Group them according to top package name. +- Show a `YAML` snippet with the plugin argument options. + - Arguments with default values will be presented with the default value, and be marked as optional. + - If an argument has a type annotation, that will be included in the documentation. + +In addition, if the plugin class has a docstring, the content in the docstring will +be used as a short introduction to the plugin. If the docstring has several parts, +when split by a line containing only `---`, they will be used as follows: +1. First part is the introduction to the plugin. +2. Second part is a more detailed explanation of the plugin arguments. +3. Third part is information regarding plugin data input. E.g assumptions, +prerequisites and/or required/assumed data format. + +Since `docsify` is used behind the scenes, you can create information boxes, warning boxes +and use GitHub emojis :bowtie: in the plugin docstring. +See [`docsify` documentation](https://docsify.js.org/#/) for details. + +[KaTeX](https://katex.org/) is also used behind the scenes, meaning that you can add +math (TeX syntax) to your docstrings and get it rendered in the auto-built +documentation. Remember that `\` is an escape character in Python, i.e. either +escape it (`\\`) or use raw strings: +```python +class HistoryMatch(WebvizPluginABC): + r"""This is a docstring with some inline math $\alpha$ and some block math: + +$$\alpha = \frac{\beta}{\gamma}$$ +""" ``` -Officially updated built end-user documentation (i.e. information to the -person setting up the configuration file) is -[hosted here on github](https://equinor.github.io/webviz-config/). +Example of auto-built documentation for `webviz-config` can be seen +[here on github](https://equinor.github.io/webviz-config/). diff --git a/INTRODUCTION.md b/INTRODUCTION.md new file mode 100644 index 00000000..65081e5c --- /dev/null +++ b/INTRODUCTION.md @@ -0,0 +1,101 @@ +# Webviz introduction + +### Fundamental configuration + +A configuration consists of some mandatory properties, e.g. app title, +and one or more pages. A page has a title and some content. +Each page can contain as many plugins as you want. + +Plugins represent predefined content, which can take one or more arguments. +Lists and descriptions of installed plugins can be found on the other subpages. + +Content which is not plugins is interpreted as text paragraphs. + +A simple example configuration: +```yaml +# This is a webviz configuration file example. +# The configuration files use the YAML standard (https://en.wikipedia.org/wiki/YAML). + +title: Reek Webviz Demonstration + +pages: + + - title: Front page + content: + - BannerImage: + image: ./example_banner.png + title: My banner image + - Webviz created from a configuration file. + + - title: Markdown example + content: + - Markdown: + markdown_file: ./example-markdown.md +``` + +### Command line usage + +#### Get documentation + +You can always run `webviz --help` to see available command line options. +To see command line options on a subcommand, run e.g. `webviz build --help`. + +:books: To open the `webviz` documentation on all installed plugins, run `webviz docs`. + +#### Portable vs. non-portable + +Assuming you have a configuration file `your_config.yml`, +there are two main usages of `webviz`: + +```bash +webviz build your_config.yml +``` +and +```bash +webviz build your_config.yml --portable ./some_output_folder +python ./some_output_folder/webviz_app.py +``` + +**Portable** + +The portable way is useful when one or more plugins included in the configuration need to do +some time-consuming data aggregation on their own, before presenting it to the user. +The time-consuming part will then be done in the `build` step, and you can run your +created application as many time as you want afterwards, with as little waiting +time as possible. + +The `--portable` way also has the benefit of creating a :whale: Docker setup for your +application - ready to be deployed to e.g. a cloud provider. + +**Non-portable** + +Non-portable is the easiest way if none of the plugins +have time-consuming data aggregration to do. + +A feature in Dash, used by `webviz` is [hot reload](https://community.plot.ly/t/announcing-hot-reload/14177). +When the Dash Python code file is saved, the content seen in the web browser is +automatically reloaded (no need for localhost server restart). This feature is passed on to +the Webviz configuration utility, meaning that if you run +```bash +webviz build ./examples/basic_example.yaml +``` +and then modify `./examples/basic_example.yaml` while the Webviz application is +still running, a hot reload will occur. + +#### Localhost certificate + +For quick local analysis, `webviz-config` uses `https` and runs on `localhost`. +In order to create your personal :lock: `https` certificate (only valid for `localhost`), run +```bash +webviz certificate --auto-install +``` +Certificate installation guidelines will be given when running the command. + +#### User preferences + +You can set preferred :rainbow: theme and/or :earth_africa: browser, such that `webviz` remembers it for later +runs. E.g. + +```bash +webviz preferences --theme equinor --browser firefox +``` diff --git a/README.md b/README.md index 7658633f..26c5cb10 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- +

Democratizing Python web applications

@@ -42,21 +42,21 @@ Example configuration file and information about the standard plugins can be see ### Installation -The simplest way of installing `webviz-config` is to run +The recommended and simplest way of installing `webviz-config` is to run ```bash pip install webviz-config ``` -If you want to download the latest source code and install it manually you +If you want to develop `webviz-config` and install the latest source code manually you +can do something along the lines of: can run ```bash git clone git@github.com:equinor/webviz-config.git cd ./webviz-config -pip install . +npm ci --ignore-scripts && npm run postinstall +pip install -e . ``` -### Usage - After installation, there is a console script named `webviz` available. You can test the installation by using the provided test configuration file, ```bash @@ -74,28 +74,11 @@ The optional arguments can be seen when running ```bash webviz --help ``` -For example will -```bash -webviz build ./examples/basic_example.yaml --portable ./my_portable_app -``` -create a portable instance (with corresponding Dockerfile) and store it in the provided folder. -A feature in Dash is [hot reload](https://community.plot.ly/t/announcing-hot-reload/14177). -When the Dash Python code file is saved, the content seen in the web browser is -automatically reloaded (no need for localhost server restart). This feature is passed on to -the Webviz configuration utility, meaning that if the user runs -```bash -webviz build ./examples/basic_example.yaml -``` -and then modifies `./examples/basic_example.yaml` while the Webviz application is -still running, a hot reload will occur. +### Usage -For quick local analysis, `webviz-config` uses `https` and runs on `localhost`. -In order to create your personal `https` certificate (only valid for `localhost`), run -```bash -webviz certificate --auto-install -``` -Certificate installation guidelines will be given when running the command. +See [the introduction](./INTRODUCTION.md) page for information on how you +create a `webviz` configuration file and use it. ### Creating new plugins diff --git a/docs/.gitignore b/docs/.gitignore deleted file mode 100644 index 8d2ac5c4..00000000 --- a/docs/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -node_modules -_build diff --git a/docs/assets/webviz_doc.css b/docs/assets/webviz_doc.css deleted file mode 100644 index 5075391b..00000000 --- a/docs/assets/webviz_doc.css +++ /dev/null @@ -1,50 +0,0 @@ -body { - background-image: linear-gradient(150deg, rgba(255,255,255), rgba(240,240,240)); -} - -pre { - border: 1px solid grey; -} - -hr { - margin-top: 20px; - margin-bottom: 20px; - border: 1px solid rgba(0, 0, 0, 0.1); -} - -div.container { - width: 80%; - margin: 0 auto; - padding: 15px; -} - -.hljs { - display: block; - overflow-x: auto; - padding: 1.5em; - color: #243746; - background: rgb(250, 250, 250); -} - -.hljs-comment, -.hljs-quote { - color: rgb(100, 100, 100); - font-style: italic; -} - -.hljs-number, -.hljs-literal, -.hljs-variable { - color: #007079; -} - -.hljs-string { - color: #7d0023; -} - -.hljs-tag, -.hljs-name, -.hljs-attribute { - color: #d5EAF4; - font-weight: normal; -} diff --git a/docs/build_docs.py b/docs/build_docs.py deleted file mode 100644 index f7e50351..00000000 --- a/docs/build_docs.py +++ /dev/null @@ -1,130 +0,0 @@ -"""Builds automatic documentation of the installed webviz config plugins. -The documentation is designed to be used by the YAML configuration file end -user. Sphinx has not been used due to - - 1) Sphinx is geared towards Python end users, and templateing of apidoc output - is not yet supported (https://github.com/sphinx-doc/sphinx/issues/3545). - - 2) It is a small problem to be solved, and most of the Sphinx machinery - is not needed. - -Overall workflow is: - * Finds all installed plugins. - * Automatically reads docstring and __init__ function signature (both - argument names and which arguments have default values). - * Output the extracted plugin information in html using jinja2. -""" - -import shutil -import inspect -import pathlib -from importlib import import_module -from collections import defaultdict -import jinja2 -from markdown import markdown -import webviz_config.plugins -from webviz_config._config_parser import SPECIAL_ARGS - -SCRIPT_DIR = pathlib.Path(__file__).resolve().parent -BUILD_DIR = SCRIPT_DIR / "_build" -TEMPLATE_FILE = SCRIPT_DIR / "templates" / "index.html.jinja2" -EXAMPLE = SCRIPT_DIR / ".." / "examples" / "basic_example.yaml" - - -def escape_all(input_string): - """Escapes any html or utf8 character in the given string. - """ - - no_html = jinja2.escape(input_string) - no_utf8 = no_html.encode("ascii", "xmlcharrefreplace").decode() - pass_through = jinja2.Markup(no_utf8) - return pass_through - - -def convert_docstring(doc): - """Convert docstring to markdown. - """ - - return "" if doc is None else markdown(doc, extensions=["fenced_code"]) - - -def get_plugin_documentation(): - """Get all installed plugins, and document them by grabbing docstring - and input arguments / function signature. - """ - - plugins = inspect.getmembers(webviz_config.plugins, inspect.isclass) - - plugin_doc = [] - - for plugin in plugins: - reference = plugin[1] - - plugin_info = {} - - plugin_info["name"] = plugin[0] - plugin_info["doc"] = convert_docstring(reference.__doc__) - - argspec = inspect.getfullargspec(reference.__init__) - plugin_info["args"] = [ - arg for arg in argspec.args if arg not in SPECIAL_ARGS - ] - - plugin_info["values"] = defaultdict(lambda: "some value") - - if argspec.defaults is not None: - for arg, default in dict( - zip(reversed(argspec.args), reversed(argspec.defaults)) - ).items(): - plugin_info["values"][ - arg - ] = f"{default} # Optional (default value shown here)." - - module = inspect.getmodule(reference) - plugin_info["module"] = module.__name__ - - package = inspect.getmodule(module).__package__ - plugin_info["package"] = package - plugin_info["package_doc"] = convert_docstring( - import_module(package).__doc__ - ) - - if not plugin_info["name"].startswith("Example"): - plugin_doc.append(plugin_info) - - # Sort the plugins by package: - - package_ordered = defaultdict(lambda: {"plugins": []}) - - for plugin in sorted(plugin_doc, key=lambda x: (x["module"], x["name"])): - package = plugin["package"] - package_ordered[package]["plugins"].append(plugin) - package_ordered[package]["doc"] = plugin["package_doc"] - - return package_ordered - - -def get_basic_example(): - with open(EXAMPLE) as fh: - return escape_all(fh.read()) - - -if __name__ == "__main__": - - template_data = { - "packages": get_plugin_documentation(), - "basic_example": get_basic_example(), - } - - with open(TEMPLATE_FILE) as fh: - template = jinja2.Template(fh.read()) - - if BUILD_DIR.exists(): - shutil.rmtree(BUILD_DIR) - - shutil.copytree(SCRIPT_DIR / "assets", BUILD_DIR / "assets") - - with open(BUILD_DIR / "index.html", "w") as fh: - fh.write(template.render(template_data)) - - print(f"Output available in {BUILD_DIR}") diff --git a/docs/package-lock.json b/docs/package-lock.json deleted file mode 100644 index 32a314f9..00000000 --- a/docs/package-lock.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "requires": true, - "lockfileVersion": 1, - "dependencies": { - "highlight.js": { - "version": "9.14.2", - "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-9.14.2.tgz", - "integrity": "sha512-Nc6YNECYpxyJABGYJAyw7dBAYbXEuIzwzkqoJnwbc1nIpCiN+3ioYf0XrBnLiyyG0JLuJhpPtt2iTSbXiKLoyA==" - } - } -} diff --git a/docs/package.json b/docs/package.json deleted file mode 100644 index 87322c5d..00000000 --- a/docs/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "dependencies": { - "highlight.js": "^9.14.2" - } -} diff --git a/docs/templates/index.html.jinja2 b/docs/templates/index.html.jinja2 deleted file mode 100644 index c7ebc85e..00000000 --- a/docs/templates/index.html.jinja2 +++ /dev/null @@ -1,61 +0,0 @@ - - - - - - - - - - - - - - - -
- -

Webviz configuration guide

- -

Fundamental configuration

- -A configuration consists of some mandatory properties (e.g. app title) -and one or more pages. Each page has a title, and potentially some -content. The content can be one or more items. - -Plugins represent predefined content, which takes one or more arguments. -A list and description of all available different plugins is listed below. - -Content which is not plugins are interpreted as text paragraphs. - -A basic example configuration is shown below. - -
{{ basic_example }}
- -

Plugin documentation

- -{%- for package in packages.values() %} - -{{- package["doc"] -}} - -
- -{%- for plugin in package["plugins"] %} - -{{ plugin["doc"] }} -
    - {{ plugin["name"] }}:
-    {%- for arg in plugin["args"] %}
-        {{ arg }}: {{ plugin["values"][arg] }}
-    {%- endfor %}
-
-
-{% endfor %} -
-{%- endfor %} - -
- - - - - diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..144a4c34 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,142 @@ +{ + "name": "webviz-config", + "requires": true, + "lockfileVersion": 1, + "dependencies": { + "clipboard": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/clipboard/-/clipboard-2.0.6.tgz", + "integrity": "sha512-g5zbiixBRk/wyKakSwCKd7vQXDjFnAMGHoEyBogG/bw9kTD9GvdAvaoRR1ALcEzt3pVKxZR0pViekPMIS0QyGg==", + "optional": true, + "requires": { + "good-listener": "^1.2.2", + "select": "^1.1.2", + "tiny-emitter": "^2.0.0" + } + }, + "commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" + }, + "delegate": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/delegate/-/delegate-3.2.0.tgz", + "integrity": "sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw==", + "optional": true + }, + "docsify": { + "version": "4.11.4", + "resolved": "https://registry.npmjs.org/docsify/-/docsify-4.11.4.tgz", + "integrity": "sha512-Qwt98y6ddM2Wb46gRH/zQpEAvw70AlIpzVlB9Wi2u2T2REg9O+bXMpJ27F5TaRTn2bD6SF1VyZYNUfimpihZwQ==", + "requires": { + "dompurify": "^2.0.8", + "marked": "^0.7.0", + "medium-zoom": "^1.0.5", + "opencollective-postinstall": "^2.0.2", + "prismjs": "^1.19.0", + "strip-indent": "^3.0.0", + "tinydate": "^1.0.0", + "tweezer.js": "^1.4.0" + } + }, + "docsify-copy-code": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/docsify-copy-code/-/docsify-copy-code-2.1.0.tgz", + "integrity": "sha512-8vVJf/y4Wgd2C/GdNwctvZXczVQhWHNjihusrUcC0YP9V96KLCOERho+QAhyYR5/iRoQaLhoMaJEeuIQHNVLVw==" + }, + "docsify-katex": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/docsify-katex/-/docsify-katex-1.4.3.tgz", + "integrity": "sha512-3wAwgETtTjZkMF749tRRqS2yJe7RcVMBcjRVNIMgUki22wetZRF2gmPu5w6rPi6JPbyPXE5M7+rUZUloRVomZg==", + "requires": { + "katex": "^0.11.1" + } + }, + "docsify-tabs": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/docsify-tabs/-/docsify-tabs-1.4.3.tgz", + "integrity": "sha512-4rO+ukB3Si4BFxwHglL1llUBcMrfgayLxRj4F+2V/2zCun28SsO617dAYbB1iIzRpD0VlYrZop8+uNy4IHIs6g==" + }, + "dompurify": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.0.12.tgz", + "integrity": "sha512-Fl8KseK1imyhErHypFPA8qpq9gPzlsJ/EukA6yk9o0gX23p1TzC+rh9LqNg1qvErRTc0UNMYlKxEGSfSh43NDg==" + }, + "good-listener": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/good-listener/-/good-listener-1.2.2.tgz", + "integrity": "sha1-1TswzfkxPf+33JoNR3CWqm0UXFA=", + "optional": true, + "requires": { + "delegate": "^3.1.2" + } + }, + "katex": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.11.1.tgz", + "integrity": "sha512-5oANDICCTX0NqYIyAiFCCwjQ7ERu3DQG2JFHLbYOf+fXaMoH8eg/zOq5WSYJsKMi/QebW+Eh3gSM+oss1H/bww==", + "requires": { + "commander": "^2.19.0" + } + }, + "marked": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-0.7.0.tgz", + "integrity": "sha512-c+yYdCZJQrsRjTPhUx7VKkApw9bwDkNbHUKo1ovgcfDjb2kc8rLuRbIFyXL5WOEUwzSSKo3IXpph2K6DqB/KZg==" + }, + "medium-zoom": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/medium-zoom/-/medium-zoom-1.0.5.tgz", + "integrity": "sha512-aLGa6WlTuFKWvH88bqTrY5ztJMN+D0hd8UX6BYc4YSoPayppzETjZUcdVcksgaoQEMg4cZSmXPg846fTp2rjRQ==" + }, + "min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==" + }, + "opencollective-postinstall": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz", + "integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==" + }, + "prismjs": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.20.0.tgz", + "integrity": "sha512-AEDjSrVNkynnw6A+B1DsFkd6AVdTnp+/WoUixFRULlCLZVRZlVQMVWio/16jv7G1FscUxQxOQhWwApgbnxr6kQ==", + "requires": { + "clipboard": "^2.0.0" + } + }, + "select": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/select/-/select-1.1.2.tgz", + "integrity": "sha1-DnNQrN7ICxEIUoeG7B1EGNEbOW0=", + "optional": true + }, + "strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "requires": { + "min-indent": "^1.0.0" + } + }, + "tiny-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz", + "integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==", + "optional": true + }, + "tinydate": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinydate/-/tinydate-1.2.0.tgz", + "integrity": "sha512-3GwPk8VhDFnUZ2TrgkhXJs6hcMAIIw4x/xkz+ayK6dGoQmp2nUwKzBXK0WnMsqkh6vfUhpqQicQF3rbshfyJkg==" + }, + "tweezer.js": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/tweezer.js/-/tweezer.js-1.5.0.tgz", + "integrity": "sha512-aSiJz7rGWNAQq7hjMK9ZYDuEawXupcCWgl3woQQSoDP2Oh8O4srWb/uO1PzzHIsrPEOqrjJ2sUb9FERfzuBabQ==" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..c2eaf06a --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "webviz-config", + "description": "Documentation system for webviz-config plugins", + "scripts": { + "postinstall": "cd ./node_modules; cp ./docsify/lib/docsify.min.js ./docsify/lib/themes/vue.css ./docsify-tabs/dist/docsify-tabs.min.js ./prismjs/components/prism-bash.min.js ./prismjs/components/prism-python.min.js ./prismjs/components/prism-yaml.min.js ./docsify-copy-code/dist/docsify-copy-code.min.js ./katex/dist/katex.min.css ./docsify-katex/dist/docsify-katex.js ../INTRODUCTION.md ../webviz_config/_docs/static/; mkdir -p ../webviz_config/_docs/static/fonts; cp ./katex/dist/fonts/*.woff2 ../webviz_config/_docs/static/fonts/; cd .." + }, + "author": "Equinor", + "license": "MIT", + "dependencies": { + "docsify": "^4.11.4", + "docsify-copy-code": "^2.1.0", + "docsify-katex": "^1.4.3", + "docsify-tabs": "^1.4.3", + "katex": "^0.11.1", + "prismjs": "^1.20.0" + } +} diff --git a/setup.py b/setup.py index 555828ea..0150153e 100644 --- a/setup.py +++ b/setup.py @@ -23,10 +23,12 @@ packages=find_packages(exclude=["tests"]), package_data={ "webviz_config": [ - "templates/*", + "_docs/static/*", + "_docs/static/fonts/*", "static/*", "static/.dockerignore", "static/assets/*", + "templates/*", "themes/default_assets/*", ] }, @@ -42,6 +44,7 @@ "pandas>=0.24", "pyarrow>=0.16", "pyyaml>=5.1", + "typing-extensions>=3.7", # Needed on Python < 3.8 "webviz-core-components>=0.0.19", ], tests_require=TESTS_REQUIRES, diff --git a/webviz_config/_docs/__init__.py b/webviz_config/_docs/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/webviz_config/_docs/_build_docs.py b/webviz_config/_docs/_build_docs.py new file mode 100644 index 00000000..5bcb774d --- /dev/null +++ b/webviz_config/_docs/_build_docs.py @@ -0,0 +1,162 @@ +"""Builds automatic documentation of the installed webviz-config plugins. +The documentation is designed to be used by the YAML configuration file end +user. Sphinx has not been used, as the documentation from Sphinx is geared +mostly towards Python end users. It is also a small task generating `webviz` +documentation, and most of the Sphinx machinery is not needed. + +Overall workflow is: + * Find all installed plugins. + * Automatically read docstring and __init__ function signatures (both + argument names and which arguments have default values). + * Output the extracted plugin information into docsify input using jinja2. +""" + +import shutil +import inspect +import pathlib +from importlib import import_module +from collections import defaultdict +from typing import Any, Dict, Optional, Tuple + +import pkg_resources +import jinja2 +from typing_extensions import TypedDict + +import webviz_config.plugins +from webviz_config._config_parser import SPECIAL_ARGS + + +class PluginInfo(TypedDict): + arg_strings: Dict[str, str] + argument_description: Optional[str] + data_input: Optional[str] + description: Optional[str] + module: str + name: str + package: str + package_doc: Optional[str] + package_version: str + + +def _document_plugin(plugin: Tuple[str, Any]) -> PluginInfo: + """Takes in a tuple (from e.g. inspect.getmembers), and returns + a dictionary according to the type definition PluginInfo. + """ + + name, reference = plugin + docstring = reference.__doc__ if reference.__doc__ is not None else "" + docstring_parts = docstring.strip().split("\n---\n") + argspec = inspect.getfullargspec(reference.__init__) + module = inspect.getmodule(reference) + subpackage = inspect.getmodule(module).__package__ # type: ignore + top_package_name = subpackage.split(".")[0] # type: ignore + + plugin_info: PluginInfo = { + "arg_strings": {arg: "" for arg in argspec.args if arg not in SPECIAL_ARGS}, + "argument_description": docstring_parts[1] + if len(docstring_parts) > 1 + else None, + "data_input": docstring_parts[2] if len(docstring_parts) > 2 else None, + "description": docstring_parts[0] if docstring != "" else None, + "name": name, + "module": module.__name__, # type: ignore + "package": top_package_name, + "package_doc": import_module(subpackage).__doc__, # type: ignore + "package_version": pkg_resources.get_distribution(top_package_name).version, + } + + # Add default value and the string '# Optional' to plugin + # arguments with default values: + if argspec.defaults is not None: + for arg, default in dict( + zip(reversed(argspec.args), reversed(argspec.defaults)) + ).items(): + if default == "": + default = "''" + plugin_info["arg_strings"][arg] = f"{default} # Optional." + + # ...and for the other arguments add '# Required': + for arg, string in plugin_info["arg_strings"].items(): + if string == "": + plugin_info["arg_strings"][arg] = " # Required." + + # Add a human readable type hint (for arguments with type annotation): + for arg, annotation in argspec.annotations.items(): + if arg in plugin_info["arg_strings"]: + plugin_info["arg_strings"][ + arg + ] += f" Type {_annotation_to_string(annotation)}." + + return plugin_info + + +def get_plugin_documentation() -> defaultdict: + """Find all installed webviz plugins, and then document them + by grabbing docstring and input arguments / function signature. + """ + + plugin_doc = [ + _document_plugin(plugin) + for plugin in inspect.getmembers(webviz_config.plugins, inspect.isclass) + if not plugin[0].startswith("Example") + ] + + # Sort the plugins by package: + package_ordered: defaultdict = defaultdict(lambda: {"plugins": []}) + for sorted_plugin in sorted(plugin_doc, key=lambda x: (x["module"], x["name"])): + package = sorted_plugin["package"] + package_ordered[package]["plugins"].append(sorted_plugin) + package_ordered[package]["doc"] = sorted_plugin["package_doc"] + package_ordered[package]["version"] = sorted_plugin["package_version"] + + return package_ordered + + +def _annotation_to_string(annotation: Any) -> str: + """Takes in a type annotation (that could come from e.g. inspect.getfullargspec) + and transforms it into a human readable string. + """ + + def remove_fix(string: str, fix: str, prefix: bool = True) -> str: + if prefix and string.startswith(fix): + return string[len(fix) :] + if not prefix and string.endswith(fix): + return string[: -len(fix)] + return string + + text_type = str(annotation) + text_type = remove_fix(text_type, "typing.") + text_type = remove_fix(text_type, "", prefix=False) + text_type = text_type.replace("pathlib.Path", "str (corresponding to a path)") + + return text_type + + +def build_docs(build_directory: pathlib.Path) -> None: + + # From Python 3.8, copytree gets an argument dirs_exist_ok. + # Then the rmtree command can be removed. + shutil.rmtree(build_directory) + shutil.copytree( + pathlib.Path(__file__).resolve().parent / "static", build_directory, + ) + + template_environment = jinja2.Environment( # nosec + loader=jinja2.PackageLoader("webviz_config", "templates"), + undefined=jinja2.StrictUndefined, + autoescape=False, + ) + + plugin_documentation = get_plugin_documentation() + + template = template_environment.get_template("README.md.jinja2") + for package_name, package_doc in plugin_documentation.items(): + (build_directory / (package_name + ".md")).write_text( + template.render({"package_name": package_name, "package_doc": package_doc}) + ) + + template = template_environment.get_template("sidebar.md.jinja2") + (build_directory / "sidebar.md").write_text( + template.render({"packages": plugin_documentation.keys()}) + ) diff --git a/webviz_config/_docs/open_docs.py b/webviz_config/_docs/open_docs.py new file mode 100644 index 00000000..2146a606 --- /dev/null +++ b/webviz_config/_docs/open_docs.py @@ -0,0 +1,57 @@ +import shutil +import pathlib +import tempfile +import argparse +import logging + +import flask + +import webviz_config.utils +from ._build_docs import build_docs + + +def _start_doc_app(build_directory: pathlib.Path) -> None: + + app = flask.Flask(__name__, static_folder=str(build_directory), static_url_path="") + app.config["SEND_FILE_MAX_AGE_DEFAULT"] = 0 + + @app.route("/") + def _index() -> str: + return (build_directory / "index.html").read_text() + + webviz_config.utils.silence_flask_startup() + logging.getLogger("werkzeug").setLevel(logging.WARNING) + + port = webviz_config.utils.get_available_port(preferred_port=5050) + token = webviz_config.LocalhostToken(app, port).one_time_token + webviz_config.utils.LocalhostOpenBrowser(port, token) + + app.run( + host="localhost", + port=port, + debug=False, + ssl_context=webviz_config.certificate.LocalhostCertificate().ssl_context, + ) + + +def open_docs(args: argparse.Namespace) -> None: + + if args.portable is None: + build_directory = pathlib.Path(tempfile.mkdtemp()) + else: + build_directory = pathlib.Path(args.portable).resolve() + if build_directory.exists(): + if not args.force: + raise ValueError( + f"{build_directory} already exists. Either add --force or change output folder." + ) + shutil.rmtree(build_directory) + build_directory.mkdir(parents=True) + + try: + build_docs(build_directory) + if not args.skip_open: + _start_doc_app(build_directory) + finally: + if args.portable is None: + shutil.rmtree(build_directory) diff --git a/webviz_config/_docs/static/README.md b/webviz_config/_docs/static/README.md new file mode 100644 index 00000000..b26acebf --- /dev/null +++ b/webviz_config/_docs/static/README.md @@ -0,0 +1,57 @@ +# Webviz introduction + +## Usage {docsify-ignore} + +Assuming you have a configuration file `your_config.yml`, +there are two main usages of `webviz`: + +```bash +webviz build your_config.yml +``` +and +```bash +webviz build your_config.yml --portable ./some_output_folder +python ./some_output_folder/webviz_app.py +``` + +The latter is useful when one or more plugins included in the configuration need to do +some time-consuming data aggregation on their own, before presenting it to the user. +The time-consuming part will then be done in the `build` step, and you can run your +created application as many time as you want afterwards, with as little waiting +time as possible). + +The `--portable` way also has the benefit of creating a :whale: Docker setup for your +application - ready to be deployed to e.g. a cloud provider. + +### Fundamental configuration {docsify-ignore} + +A configuration consists of some mandatory properties, e.g. app title, +and one or more pages. A page has a title and some content. +Each page can contain as many plugins as you want. + +Plugins represent predefined content, which can take one or more arguments. +Lists and descriptions of installed plugins can be found on the other subpages. + +Content which is not plugins is interpreted as text paragraphs. + +A simple example configuration: +```yaml +# This is a webviz configuration file example. +# The configuration files use the YAML standard (https://en.wikipedia.org/wiki/YAML). + +title: Reek Webviz Demonstration + +pages: + + - title: Front page + content: + - BannerImage: + image: ./example_banner.png + title: My banner image + - Webviz created from a configuration file. + + - title: Markdown example + content: + - Markdown: + markdown_file: ./example-markdown.md +``` diff --git a/webviz_config/_docs/static/index.html b/webviz_config/_docs/static/index.html new file mode 100644 index 00000000..5ae05660 --- /dev/null +++ b/webviz_config/_docs/static/index.html @@ -0,0 +1,24 @@ + + + + + + + + + + + + +
Loading Webviz documentation...
+ + + + + + + + + + + diff --git a/webviz_config/_docs/static/webviz-doc.css b/webviz_config/_docs/static/webviz-doc.css new file mode 100644 index 00000000..21b23b78 --- /dev/null +++ b/webviz_config/_docs/static/webviz-doc.css @@ -0,0 +1,26 @@ +.app-name-link > img { + width: 120px; + margin-bottom: 10px; +} + +button.docsify-copy-code-button { + border-radius: 5px; +} + +button { + font-family: inherit; +} + +.plugin-doc { + background-color: white; + margin-top: 20px; + margin-bottom: 20px; + padding-left: 10px; + padding-right: 10px; + border: 1px solid rgb(240, 240, 240); + border-radius: 3px; +} + +.plugin-doc:hover { + box-shadow: 5px 5px 10px 3px rgba(230, 230, 230); +} diff --git a/webviz_config/_docs/static/webviz-doc.js b/webviz_config/_docs/static/webviz-doc.js new file mode 100644 index 00000000..24b476d0 --- /dev/null +++ b/webviz_config/_docs/static/webviz-doc.js @@ -0,0 +1,14 @@ +window.$docsify = { + logo: "./webviz-logo.svg", + homepage: "INTRODUCTION.md", + name: "Webviz", + loadSidebar: "sidebar.md", + subMaxLevel: 4, + copyCode: { + buttonText : "Copy", + }, + tabs: { + sync: false, + theme: "material" + } +} diff --git a/docs/assets/webviz-logo.svg b/webviz_config/_docs/static/webviz-logo.svg similarity index 100% rename from docs/assets/webviz-logo.svg rename to webviz_config/_docs/static/webviz-logo.svg diff --git a/webviz_config/command_line.py b/webviz_config/command_line.py index 8a5020c2..b9ddb151 100644 --- a/webviz_config/command_line.py +++ b/webviz_config/command_line.py @@ -1,7 +1,9 @@ import argparse +import pathlib from ._build_webviz import build_webviz from .certificate._certificate_generator import create_ca +from ._docs.open_docs import open_docs from ._user_preferences import set_user_preferences, get_user_preference @@ -76,6 +78,36 @@ def main() -> None: parser_cert.set_defaults(func=create_ca) + # Add "documentation" parser: + + parser_docs = subparsers.add_parser( + "docs", help="Get documentation on installed Webviz plugins", + ) + + parser_docs.add_argument( + "--portable", + type=pathlib.Path, + default=None, + metavar="OUTPUTFOLDER", + help="Build documentation in given folder, " + "which then can be deployed directly to e.g. GitHub pages.", + ) + + parser_docs.add_argument( + "--force", + action="store_true", + help="Overwrite existing output (this flag " + "only has effect if --portable is given)", + ) + + parser_docs.add_argument( + "--skip-open", + action="store_true", + help="Skip opening the documentation automatically in browser.", + ) + + parser_docs.set_defaults(func=open_docs) + # Add "preferences" parser: parser_preferences = subparsers.add_parser( diff --git a/webviz_config/plugins/__init__.py b/webviz_config/plugins/__init__.py index fcc2b261..a08ccb98 100644 --- a/webviz_config/plugins/__init__.py +++ b/webviz_config/plugins/__init__.py @@ -1,6 +1,4 @@ -"""### _Basic plugins_ - -These are the basic Webviz configuration plugins, distributed through +"""These are the basic Webviz configuration plugins, distributed through the utility itself. """ diff --git a/webviz_config/plugins/_banner_image.py b/webviz_config/plugins/_banner_image.py index ecbd5da9..2daaf0d2 100644 --- a/webviz_config/plugins/_banner_image.py +++ b/webviz_config/plugins/_banner_image.py @@ -8,17 +8,17 @@ class BannerImage(WebvizPluginABC): - """### Banner image - -Adds a full width _banner image_, with an optional overlayed title. + """Adds a full width banner image, with an optional overlayed title. Useful on e.g. the front page for introducing a field or project. -* `image`: Path to the picture you want to add. Either absolute path or - relative to the configuration file. -* `title`: Title which will be overlayed over the banner image. -* `color`: Color to be used for the font. -* `shadow`: Set to `False` if you do not want text shadow for the title. -* `height`: Height of the banner image (in pixels). +--- + +* **`image`:** Path to the picture you want to add. \ + Either absolute path or relative to the configuration file. +* **`title`:** Title which will be overlayed over the banner image. +* **`color`:** Color to be used for the font. +* **`shadow`:** Set to `False` if you do not want text shadow for the title. +* **`height`:** Height of the banner image (in pixels). """ TOOLBAR_BUTTONS: List[str] = [] diff --git a/webviz_config/plugins/_data_table.py b/webviz_config/plugins/_data_table.py index 800cf3d2..eab3dc66 100644 --- a/webviz_config/plugins/_data_table.py +++ b/webviz_config/plugins/_data_table.py @@ -10,18 +10,18 @@ class DataTable(WebvizPluginABC): - """### Data table - -Adds a table to the webviz instance, using tabular data from a provided csv file. + """Adds a table to the webviz instance, using tabular data from a provided csv file. If feature is requested, the data could also come from a database. -* `csv_file`: Path to the csv file containing the tabular data. Either absolute +--- + +* **`csv_file`:** Path to the csv file containing the tabular data. Either absolute \ path or relative to the configuration file. -* `sorting`: If `True`, the table can be sorted interactively based +* **`sorting`:** If `True`, the table can be sorted interactively based \ on data in the individual columns. -* `filtering`: If `True`, the table can be filtered based on values in the +* **`filtering`:** If `True`, the table can be filtered based on values in the \ individual columns. -* `pagination`: If `True`, only a subset of the table is displayed at once. +* **`pagination`:** If `True`, only a subset of the table is displayed at once. \ Different subsets can be viewed from 'previous/next' buttons """ diff --git a/webviz_config/plugins/_embed_pdf.py b/webviz_config/plugins/_embed_pdf.py index 8d0b7259..79f8ec69 100644 --- a/webviz_config/plugins/_embed_pdf.py +++ b/webviz_config/plugins/_embed_pdf.py @@ -7,17 +7,16 @@ class EmbedPdf(WebvizPluginABC): - """### Embed PDF file + """Embeds a given PDF file into the page. -Embeds a given PDF file into the page. +!> Webviz does not scan your PDF for malicious code. Make sure it comes from a trusted source. +--- -* `pdf_file`: Path to the PDF file to include. Either absolute path or +* **`pdf_file`:** Path to the PDF file to include. Either absolute path or \ relative to the configuration file. -* `height`: Height of the PDF object (in percent of viewport height). -* `width`: Width of the PDF object (in percent of available space). +* **`height`:** Height of the PDF object (in percent of viewport height). +* **`width`:** Width of the PDF object (in percent of available space). -_Note_: Webviz does not scan your PDF for malicious code. -Make sure it comes from a trusted source. """ def __init__(self, pdf_file: Path, height: int = 80, width: int = 100): diff --git a/webviz_config/plugins/_markdown.py b/webviz_config/plugins/_markdown.py index 635ca634..009cfec4 100644 --- a/webviz_config/plugins/_markdown.py +++ b/webviz_config/plugins/_markdown.py @@ -81,21 +81,25 @@ def handleMatch(self, m, data: str) -> tuple: # type: ignore[no-untyped-def] class Markdown(WebvizPluginABC): - """### Include Markdown + """Renders and includes the content from a Markdown file. -_Note:_ The markdown syntax for images has been extended to support -(optionally) providing width and/or height for individual images. -To specify the dimensions write e.g. -```markdown -![width=40%,height=300px](./example_banner.png "Some caption") -``` +--- -Renders and includes the content from a Markdown file. Images are supported, -and should in the markdown file be given as either relative paths to -the markdown file itself, or absolute paths. +* **`markdown_file`:** Path to the markdown file to render and include. \ + Either absolute path or relative to the configuration file. + +--- + +Images are supported, and should in the markdown file be given as either +relative paths to the markdown file itself, or as absolute paths. + +> The markdown syntax for images has been extended to support \ + providing width and/or height for individual images (optional). \ + To specify the dimensions write e.g. +> ```markdown +> ![width=40%,height=300px](./example_banner.png "Some caption") +> ``` -* `markdown_file`: Path to the markdown file to render and include. Either - absolute path or relative to the configuration file. """ ALLOWED_TAGS = [ diff --git a/webviz_config/plugins/_syntax_highlighter.py b/webviz_config/plugins/_syntax_highlighter.py index fc67600b..ce058365 100644 --- a/webviz_config/plugins/_syntax_highlighter.py +++ b/webviz_config/plugins/_syntax_highlighter.py @@ -8,13 +8,13 @@ class SyntaxHighlighter(WebvizPluginABC): - """### Syntax highlighter + """Adds support for syntax highlighting of code. Language is automatically detected. -Adds support for syntax highlighting of code. Language is automatically detected. +--- -* `filename`: Path to a file containing the code to highlight. -* `dark_theme`: If `True`, the code is shown with a dark theme. Default is - `False` giving a light theme. +* **`filename`:** Path to a file containing the code to highlight. +* **`dark_theme`:** If `True`, the code is shown with a dark theme. Default is \ + `False`, giving a light theme. """ def __init__(self, filename: Path, dark_theme: bool = False): diff --git a/webviz_config/plugins/_table_plotter.py b/webviz_config/plugins/_table_plotter.py index 0e569937..9a591fdd 100644 --- a/webviz_config/plugins/_table_plotter.py +++ b/webviz_config/plugins/_table_plotter.py @@ -19,24 +19,25 @@ # pylint: disable=too-many-instance-attributes, too-many-arguments class TablePlotter(WebvizPluginABC): - """### TablePlotter - -Adds a plotter to the webviz instance, using tabular data from a provided csv file. + """Adds a plotter to the webviz instance, using tabular data from a provided csv file. If feature is requested, the data could also come from a database. -* `csv_file`: Path to the csv file containing the tabular data. Either absolute - path or relative to the configuration file. -* `plot_options`: A dictionary of plot options to initialize the plot with -* `filter_cols`: Dataframe columns that can be used to filter data -* `filter_defaults`: A dictionary with column names as keys, and a list of column values that - should be preselected in the filter. If a columm is not defined, all values - are preselected for the column. -* `column_color_discrete_maps`: A dictionary with column names as keys, each key containing a new - dictionary with the columns unique values as keys, and the color they should - be plotted with as value. Hex values needs quotes '' to not be read as comment - in the yaml config file. -* `lock`: If `True`, only the plot is shown, all dropdowns for changing - plot options are hidden. +--- + +* **`csv_file`:** Path to the csv file containing the tabular data. \ + Either absolute path or relative to the configuration file. +* **`plot_options`:** A dictionary of plot options to initialize the plot with. +* **`filter_cols`:** Dataframe columns that can be used to filter data. +* **`filter_defaults`:** A dictionary with column names as keys, \ + and a list of column values that should be preselected in the filter. \ + If a columm is not defined, all values are preselected for the column. +* **`column_color_discrete_maps`:** A dictionary with column names as keys, \ + each key containing a new dictionary with the columns \ + unique values as keys, and the color they should be \ + plotted with as value. Hex values needs quotes '' \ + to not be read as a comment. +* **`lock`:** If `True`, only the plot is shown, \ + all dropdowns for changing plot options are hidden. """ def __init__( diff --git a/webviz_config/templates/README.md.jinja2 b/webviz_config/templates/README.md.jinja2 new file mode 100644 index 00000000..a1604e25 --- /dev/null +++ b/webviz_config/templates/README.md.jinja2 @@ -0,0 +1,51 @@ +# Plugin package {{ package_name }} + +?> :bookmark: This documentation is valid for version `{{ package_doc["version"] }}` of `{{ package_name}}`. + +{% if package_doc["doc"] is not none %} +{{ package_doc["doc"] }} +{% endif %} + +--- + +{% for plugin in package_doc["plugins"] %} + +
+ +#### {{ plugin["name"] }} + + +{% if plugin["description"] is not none %} + +#### ** Description ** + +{{ plugin["description"] }} + +{% endif %} + +#### ** Arguments ** + +{% if plugin["argument_description"] is not none %} +{{ plugin["argument_description"] }} +{% endif %} + +```yaml + - {{ plugin["name"] }}: + {%- for arg, string in plugin["arg_strings"].items() %} + {{ arg }}: {{ string }} + {%- endfor %} +``` + +{% if plugin["data_input"] is not none %} + +#### ** Data input ** + +{{ plugin["data_input"] }} + +{% endif %} + + + +
+ +{% endfor %} diff --git a/webviz_config/templates/sidebar.md.jinja2 b/webviz_config/templates/sidebar.md.jinja2 new file mode 100644 index 00000000..8c42bcde --- /dev/null +++ b/webviz_config/templates/sidebar.md.jinja2 @@ -0,0 +1,4 @@ +* [Introduction](/) +{%- for package in packages %} +* [{{package}} package]({{package}}.md) +{%- endfor %}