diff --git a/ciimage/Dockerfile b/ciimage/Dockerfile
index c398fad56..25cc6fa3e 100644
--- a/ciimage/Dockerfile
+++ b/ciimage/Dockerfile
@@ -12,6 +12,7 @@ RUN apt-get -y update && apt-get -y upgrade \
&& apt-get -y install qt4-linguist-tools \
&& apt-get -y install python-dev \
&& apt-get -y install libomp-dev openssh-client \
+&& apt-get -y install -y clang libclang-dev llvm-dev flex \
&& python3 -m pip install hotdoc codecov \
&& dub fetch urld \
&& dub build urld --compiler=gdc
diff --git a/docs/README.md b/docs/README.md
index 9ed75c10b..18509c7ed 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -3,7 +3,7 @@
1. Get [hotdoc](https://hotdoc.github.io/installing.html) (0.8.9 required)
1. Run hotdoc in the docs/ directory:
- hotdoc run
+ ../meson/meson.py build/
## Upload
@@ -12,5 +12,4 @@ removes the html pages and replaces with the new content.
You can simply run:
- hotdoc run --git-upload-activate
-
+ ninja -C build/ upload
diff --git a/docs/hotdoc.json b/docs/hotdoc.json
deleted file mode 100644
index 482bc95d1..000000000
--- a/docs/hotdoc.json
+++ /dev/null
@@ -1,16 +0,0 @@
-{
- "extra_assets": [
- "images/"
- ],
- "html_extra_theme": "theme/extra/",
- "include_paths": [
- "markdown/"
- ],
- "output": "built_docs/",
- "project_name": "Meson documentation",
- "project_version": "1.0",
- "default-license": "CC-BY-SAv4.0",
- "sitemap": "sitemap.txt",
- "git_upload_repository": "git@github.com:jpakkane/jpakkane.github.io.git",
- "edit_on_github_repository": "https://github.com/mesonbuild/meson/"
-}
diff --git a/docs/markdown/Hotdoc-module.md b/docs/markdown/Hotdoc-module.md
new file mode 100644
index 000000000..7d9fc555f
--- /dev/null
+++ b/docs/markdown/Hotdoc-module.md
@@ -0,0 +1,79 @@
+---
+short-description: Hotdoc module
+authors:
+ - name: Thibault Saunier
+ email: tsaunier@igalia.com
+ years: [2018]
+ has-copyright: false
+...
+
+# Hotdoc module
+
+This module provides helper functions for generating documentation using
+[hotdoc].
+
+*Added 0.48.0*
+
+## Usage
+
+To use this module, just do: **`hotdoc = import('hotdoc')`**. The
+following functions will then be available as methods on the object
+with the name `hotdoc`. You can, of course, replace the name `hotdoc`
+with anything else.
+
+### hotdoc.generate_doc()
+
+Generates documentation using [hotdoc] and installs it into `$prefix/share/doc/html`.
+
+**Positional argument:**
+
+* `project_name`: The name of the hotdoc project
+
+**Keyworded arguments:**
+
+* `sitemap` (*[string] or [file]*) (**required**): The hotdoc sitemap file
+* `index` (*[string] or [file]*) (**required**): Location of the index file
+* `dependencies`(*[targets]*): Targets on which the documentation generation depends on.
+* `subprojects`: A list of `HotdocTarget` that are used as subprojects for hotdoc to generate
+ the documentation.
+* ... Any argument of `hotdoc` can be used replacing dashes (`-`) with underscores (`_`).
+ For a full list of available parameters, just have a look at `hotdoc help`.
+
+[file]: Reference-manual.md#files
+[string]: Reference-manual.md#string-object
+[targets]: Reference-manual.md#build-target-object
+
+**Returns:**
+
+`HotdocTarget`: A [`custom_target`](Reference-manual.md#custom-target-object) with the
+following extra methods:
+
+* `config_path`: Path to the generated `hotdoc` configuration file.
+
+### hotdoc.has_extensions()
+
+**Positional arguments:**
+
+* `...`: The hotdoc extension names to look for
+
+**No keyworded arguments**
+
+**Returns:** `true` if all the extensions where found, `false` otherwise.
+
+### Example
+
+``` meson
+hotdoc = import('hotdoc')
+
+hotdoc.generate_doc('foobar',
+ project_version: '0.1',
+ sitemap: 'sitemap.txt',
+ index: 'index.md',
+ c_sources: ['path/to/file.c'],
+ c_smart_index: true,
+ languages: ['c'],
+ install: true,
+)
+```
+
+[hotdoc]: https://hotdoc.github.io/
\ No newline at end of file
diff --git a/docs/markdown/snippets/hotdoc_module.md b/docs/markdown/snippets/hotdoc_module.md
new file mode 100644
index 000000000..4662ea22b
--- /dev/null
+++ b/docs/markdown/snippets/hotdoc_module.md
@@ -0,0 +1,22 @@
+## Hotdoc module
+
+A new module has been written to ease generation of [hotdoc](https://hotdoc.github.io/) based
+documentation. It supports complex use cases such as hotdoc subprojects (to create documentation
+portals) and makes it straight forward to leverage full capabilities of hotdoc.
+
+Simple usage:
+
+``` meson
+hotdoc = import('hotdoc')
+
+hotdoc.generate_doc(
+ 'foobar',
+ c_smart_index: true,
+ project_version: '0.1',
+ sitemap: 'sitemap.txt',
+ index: 'index.md',
+ c_sources: ['path/to/file.c'],
+ languages: ['c'],
+ install: true,
+)
+```
\ No newline at end of file
diff --git a/docs/meson.build b/docs/meson.build
new file mode 100644
index 000000000..c83d5f89f
--- /dev/null
+++ b/docs/meson.build
@@ -0,0 +1,22 @@
+project('Meson documentation', version: '1.0')
+
+hotdoc = import('hotdoc')
+documentation = hotdoc.generate_doc(meson.project_name(),
+ project_version: meson.project_version(),
+ sitemap: 'sitemap.txt',
+ build_by_default: true,
+ index: 'markdown/index.md',
+ install: false,
+ extra_assets: ['images/'],
+ include_paths: ['markdown'],
+ default_license: 'CC-BY-SAv4.0',
+ html_extra_theme: join_paths('theme', 'extra'),
+ git_upload_repository: 'git@github.com:jpakkane/jpakkane.github.io.git',
+ edit_on_github_repository: 'https://github.com/mesonbuild/meson/',
+ syntax_highlighting_activate: true,
+)
+
+run_target('upload',
+ command: [find_program('hotdoc'), 'run', '--conf-file', documentation.config_path(),
+ '--git-upload']
+)
\ No newline at end of file
diff --git a/docs/sitemap.txt b/docs/sitemap.txt
index 2d43e1806..4ba1b90df 100644
--- a/docs/sitemap.txt
+++ b/docs/sitemap.txt
@@ -29,7 +29,9 @@ index.md
Subprojects.md
Disabler.md
Modules.md
+ Dlang-module.md
Gnome-module.md
+ Hotdoc-module.md
i18n-module.md
Icestorm-module.md
Pkgconfig-module.md
@@ -40,7 +42,6 @@ index.md
RPM-module.md
Simd-module.md
Windows-module.md
- Dlang-module.md
Java.md
Vala.md
D.md
diff --git a/docs/theme/extra/templates/navbar_links.html b/docs/theme/extra/templates/navbar_links.html
index 2edce2489..c9cba131d 100644
--- a/docs/theme/extra/templates/navbar_links.html
+++ b/docs/theme/extra/templates/navbar_links.html
@@ -13,7 +13,8 @@
("Qt4-module.html","Qt4"), \
("Qt5-module.html","Qt5"), \
("RPM-module.html","RPM"), \
- ("Windows-module.html","Windows")):
+ ("Windows-module.html","Windows"), \
+ ("Hotdoc-module.html","Hotdoc")):
@tup[1]
diff --git a/mesonbuild/build.py b/mesonbuild/build.py
index d35c697ec..3debeeb41 100644
--- a/mesonbuild/build.py
+++ b/mesonbuild/build.py
@@ -1830,7 +1830,8 @@ class CustomTarget(Target):
while hasattr(ed, 'held_object'):
ed = ed.held_object
if not isinstance(ed, (CustomTarget, BuildTarget)):
- raise InvalidArguments('Can only depend on toplevel targets: custom_target or build_target (executable or a library)')
+ raise InvalidArguments('Can only depend on toplevel targets: custom_target or build_target (executable or a library) got: %s(%s)'
+ % (type(ed), ed))
self.extra_depends.append(ed)
for i in depend_files:
if isinstance(i, (File, str)):
diff --git a/mesonbuild/interpreter.py b/mesonbuild/interpreter.py
index 7dd24c5c9..f0ba4a9df 100644
--- a/mesonbuild/interpreter.py
+++ b/mesonbuild/interpreter.py
@@ -2001,7 +2001,7 @@ class Interpreter(InterpreterBase):
return [self.holderify(x) for x in item]
if isinstance(item, build.CustomTarget):
return CustomTargetHolder(item, self)
- elif isinstance(item, (int, str)) or item is None:
+ elif isinstance(item, (int, str, bool)) or item is None:
return item
elif isinstance(item, build.Executable):
return ExecutableHolder(item, self)
@@ -2027,6 +2027,9 @@ class Interpreter(InterpreterBase):
def process_new_values(self, invalues):
invalues = listify(invalues)
for v in invalues:
+ if isinstance(v, (RunTargetHolder, CustomTargetHolder, BuildTargetHolder)):
+ v = v.held_object
+
if isinstance(v, (build.BuildTarget, build.CustomTarget, build.RunTarget)):
self.add_target(v.name, v)
elif isinstance(v, list):
@@ -2045,6 +2048,8 @@ class Interpreter(InterpreterBase):
self.process_new_values(v.sources[0])
elif hasattr(v, 'held_object'):
pass
+ elif isinstance(v, (int, str, bool)):
+ pass
else:
raise InterpreterException('Module returned a value of unknown type.')
diff --git a/mesonbuild/mesonmain.py b/mesonbuild/mesonmain.py
index e8b7b3097..7dddb58e4 100644
--- a/mesonbuild/mesonmain.py
+++ b/mesonbuild/mesonmain.py
@@ -200,6 +200,9 @@ def run_script_command(args):
elif cmdname == 'msgfmthelper':
import mesonbuild.scripts.msgfmthelper as abc
cmdfunc = abc.run
+ elif cmdname == 'hotdoc':
+ import mesonbuild.scripts.hotdochelper as abc
+ cmdfunc = abc.run
elif cmdname == 'regencheck':
import mesonbuild.scripts.regen_checker as abc
cmdfunc = abc.run
diff --git a/mesonbuild/modules/hotdoc.py b/mesonbuild/modules/hotdoc.py
new file mode 100644
index 000000000..1f7368a08
--- /dev/null
+++ b/mesonbuild/modules/hotdoc.py
@@ -0,0 +1,386 @@
+# Copyright 2018 The Meson development team
+
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+
+# http://www.apache.org/licenses/LICENSE-2.0
+
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+'''This module provides helper functions for generating documentation using hotdoc'''
+
+import os
+from collections import OrderedDict
+
+from mesonbuild import mesonlib
+from mesonbuild import mlog, build
+from mesonbuild.coredata import MesonException
+from . import ModuleReturnValue
+from . import ExtensionModule
+from . import get_include_args
+from ..dependencies import Dependency, InternalDependency, ExternalProgram
+from ..interpreterbase import FeatureNew, InvalidArguments, noPosargs, noKwargs
+from ..interpreter import CustomTargetHolder
+
+
+def ensure_list(value):
+ if not isinstance(value, list):
+ return [value]
+ return value
+
+
+MIN_HOTDOC_VERSION = '0.8.100'
+
+
+class HotdocTargetBuilder:
+ def __init__(self, name, state, hotdoc, kwargs):
+ self.hotdoc = hotdoc
+ self.build_by_default = kwargs.pop('build_by_default', False)
+ self.kwargs = kwargs
+ self.name = name
+ self.state = state
+ self.include_paths = OrderedDict()
+
+ self.builddir = state.environment.get_build_dir()
+ self.sourcedir = state.environment.get_source_dir()
+ self.subdir = state.subdir
+ self.build_command = state.environment.get_build_command()
+
+ self.cmd = ['conf', '--project-name', name, "--disable-incremental-build",
+ '--output', os.path.join(self.builddir, self.subdir, self.name + '-doc')]
+
+ self._extra_extension_paths = set()
+ self.extra_assets = set()
+ self._dependencies = []
+ self._subprojects = []
+
+ def process_known_arg(self, option, types, argname=None,
+ value_processor=None, mandatory=False,
+ force_list=False):
+ if not argname:
+ argname = option.strip("-").replace("-", "_")
+
+ value, _ = self.get_value(
+ types, argname, None, value_processor, mandatory, force_list)
+
+ self.set_arg_value(option, value)
+
+ def set_arg_value(self, option, value):
+ if value is None:
+ return
+
+ if isinstance(value, bool):
+ self.cmd.append(option)
+ elif isinstance(value, list):
+ # Do not do anything on empty lists
+ if value:
+ if option:
+ self.cmd.extend([option] + value)
+ else:
+ self.cmd.extend(value)
+ else:
+ self.cmd.extend([option, value])
+
+ def check_extra_arg_type(self, arg, value):
+ if isinstance(value, list):
+ for v in value:
+ self.check_extra_arg_type(arg, v)
+ return
+
+ if not isinstance(value, (str, bool, mesonlib.File)):
+ raise InvalidArguments('Argument "%s=%s" should be a string.' % (arg, value))
+
+ def process_extra_args(self):
+ for arg, value in self.kwargs.items():
+ option = "--" + arg.replace("_", "-")
+ self.check_extra_arg_type(arg, value)
+ self.set_arg_value(option, value)
+
+ def get_value(self, types, argname, default=None, value_processor=None,
+ mandatory=False, force_list=False):
+ if not isinstance(types, list):
+ types = [types]
+ try:
+ uvalue = value = self.kwargs.pop(argname)
+ if value_processor:
+ value = value_processor(value)
+
+ for t in types:
+ if isinstance(value, t):
+ if force_list and not isinstance(value, list):
+ return [value], uvalue
+ return value, uvalue
+ raise MesonException("%s field value %s is not valid,"
+ " valid types are %s" % (argname, value,
+ types))
+ except KeyError:
+ if mandatory:
+ raise MesonException("%s mandatory field not found" % argname)
+
+ if default is not None:
+ return default, default
+
+ return None, None
+
+ def setup_extension_paths(self, paths):
+ if not isinstance(paths, list):
+ paths = [paths]
+
+ for path in paths:
+ self.add_extension_paths([path])
+
+ return []
+
+ def add_extension_paths(self, paths):
+ for path in paths:
+ if path in self._extra_extension_paths:
+ continue
+
+ self._extra_extension_paths.add(path)
+ self.cmd.extend(["--extra-extension-path", path])
+
+ def process_extra_extension_paths(self):
+ self.get_value([list, str], 'extra_extensions_paths',
+ default="", value_processor=self.setup_extension_paths)
+
+ def replace_dirs_in_string(self, string):
+ return string.replace("@SOURCE_ROOT@", self.sourcedir).replace("@BUILD_ROOT@", self.builddir)
+
+ def process_dependencies(self, deps):
+ cflags = set()
+ for dep in mesonlib.listify(ensure_list(deps)):
+ dep = getattr(dep, "held_object", dep)
+ if isinstance(dep, InternalDependency):
+ inc_args = get_include_args(dep.include_directories)
+ cflags.update([self.replace_dirs_in_string(x)
+ for x in inc_args])
+ cflags.update(self.process_dependencies(dep.libraries))
+ cflags.update(self.process_dependencies(dep.sources))
+ cflags.update(self.process_dependencies(dep.ext_deps))
+ elif isinstance(dep, Dependency):
+ cflags.update(dep.get_compile_args())
+ elif isinstance(dep, (build.StaticLibrary, build.SharedLibrary)):
+ self._dependencies.append(dep)
+ for incd in dep.get_include_dirs():
+ cflags.update(incd.get_incdirs())
+ elif isinstance(dep, HotdocTarget):
+ # Recurse in hotdoc target dependencies
+ self.process_dependencies(dep.get_target_dependencies())
+ self._subprojects.extend(dep.subprojects)
+ self.process_dependencies(dep.subprojects)
+ self.add_include_path(os.path.join(self.builddir, dep.hotdoc_conf.subdir))
+ self.cmd += ['--extra-assets=' + p for p in dep.extra_assets]
+ self.add_extension_paths(dep.extra_extension_paths)
+ elif isinstance(dep, build.CustomTarget) or isinstance(dep, build.BuildTarget):
+ self._dependencies.append(dep)
+
+ return [f.strip('-I') for f in cflags]
+
+ def process_extra_assets(self):
+ self._extra_assets, _ = self.get_value("--extra-assets", (str, list), default=[],
+ force_list=True)
+ for assets_path in self._extra_assets:
+ self.cmd.extend(["--extra-assets", assets_path])
+
+ def process_subprojects(self):
+ _, value = self.get_value([
+ list, HotdocTarget], argname="subprojects",
+ force_list=True, value_processor=self.process_dependencies)
+
+ if value is not None:
+ self._subprojects.extend(value)
+
+ def flatten_config_command(self):
+ cmd = []
+ for arg in mesonlib.listify(self.cmd, flatten=True):
+ if isinstance(arg, mesonlib.File):
+ arg = arg.absolute_path(self.state.environment.get_source_dir(),
+ self.state.environment.get_build_dir())
+
+ cmd.append(arg)
+
+ return cmd
+
+ def generate_hotdoc_config(self):
+ cwd = os.path.abspath(os.curdir)
+ ncwd = os.path.join(self.sourcedir, self.subdir)
+ mlog.log('Generating Hotdoc configuration for: ', mlog.bold(self.name))
+ os.chdir(ncwd)
+ self.hotdoc.run_hotdoc(self.flatten_config_command())
+ os.chdir(cwd)
+
+ def ensure_file(self, value):
+ if isinstance(value, list):
+ res = []
+ for val in value:
+ res.append(self.ensure_file(val))
+ return res
+
+ if not isinstance(value, mesonlib.File):
+ return mesonlib.File.from_source_file(self.sourcedir, self.subdir, value)
+
+ return value
+
+ def ensure_dir(self, value):
+ if os.path.isabs(value):
+ _dir = value
+ else:
+ _dir = os.path.join(self.sourcedir, self.subdir, value)
+
+ if not os.path.isdir(_dir):
+ raise InvalidArguments('"%s" is not a directory.' % _dir)
+
+ return os.path.relpath(_dir, os.path.join(self.builddir, self.subdir))
+
+ def check_forbiden_args(self):
+ for arg in ['conf_file']:
+ if arg in self.kwargs:
+ raise InvalidArguments('Argument "%s" is forbidden.' % arg)
+
+ def add_include_path(self, path):
+ self.include_paths[path] = path
+
+ def make_targets(self):
+ self.check_forbiden_args()
+ file_types = (str, mesonlib.File)
+ self.process_known_arg("--index", file_types, mandatory=True, value_processor=self.ensure_file)
+ self.process_known_arg("--sitemap", file_types, mandatory=True, value_processor=self.ensure_file)
+ self.process_known_arg("--html-extra-theme", str, value_processor=self.ensure_dir)
+ self.process_known_arg(None, list, "include_paths", force_list=True,
+ value_processor=lambda x: [self.add_include_path(self.ensure_dir(v)) for v in ensure_list(x)])
+ self.process_known_arg('--c-include-directories',
+ [Dependency, build.StaticLibrary, build.SharedLibrary, list], argname="dependencies",
+ force_list=True, value_processor=self.process_dependencies)
+ self.process_extra_assets()
+ self.process_extra_extension_paths()
+ self.process_subprojects()
+
+ install, install = self.get_value(bool, "install", mandatory=False)
+ self.process_extra_args()
+
+ fullname = self.name + '-doc'
+ hotdoc_config_name = fullname + '.json'
+ hotdoc_config_path = os.path.join(
+ self.builddir, self.subdir, hotdoc_config_name)
+ with open(hotdoc_config_path, 'w') as f:
+ f.write('{}')
+
+ self.cmd += ['--conf-file', hotdoc_config_path]
+ self.add_include_path(os.path.join(self.builddir, self.subdir))
+ self.add_include_path(os.path.join(self.sourcedir, self.subdir))
+
+ depfile = os.path.join(self.builddir, self.subdir, self.name + '.deps')
+ self.cmd += ['--deps-file-dest', depfile]
+
+ for path in self.include_paths.keys():
+ self.cmd.extend(['--include-path', path])
+ self.generate_hotdoc_config()
+
+ target_cmd = self.build_command + ["--internal", "hotdoc"] + \
+ self.hotdoc.get_command() + ['run', '--conf-file', hotdoc_config_name] + \
+ ['--builddir', os.path.join(self.builddir, self.subdir)]
+
+ target = HotdocTarget(fullname,
+ subdir=self.subdir,
+ subproject=self.state.subproject,
+ hotdoc_conf=mesonlib.File.from_built_file(
+ self.subdir, hotdoc_config_name),
+ extra_extension_paths=self._extra_extension_paths,
+ extra_assets=self._extra_assets,
+ subprojects=self._subprojects,
+ command=target_cmd,
+ depends=self._dependencies,
+ output=fullname,
+ depfile=os.path.basename(depfile),
+ build_by_default=self.build_by_default)
+
+ install_script = None
+ if install is True:
+ install_script = HotdocRunScript(self.build_command, [
+ "--internal", "hotdoc",
+ "--install", os.path.join(fullname, 'html'),
+ '--name', self.name,
+ '--builddir', os.path.join(self.builddir, self.subdir)] +
+ self.hotdoc.get_command() +
+ ['run', '--conf-file', hotdoc_config_name])
+
+ return (target, install_script)
+
+
+class HotdocTargetHolder(CustomTargetHolder):
+ def __init__(self, target, interp):
+ super().__init__(target, interp)
+ self.methods.update({'config_path': self.config_path_method})
+
+ @noPosargs
+ @noKwargs
+ def config_path_method(self, *args, **kwargs):
+ conf = self.held_object.hotdoc_conf.absolute_path(self.interpreter.environment.source_dir,
+ self.interpreter.environment.build_dir)
+ return self.interpreter.holderify(conf)
+
+
+class HotdocTarget(build.CustomTarget):
+ def __init__(self, name, subdir, subproject, hotdoc_conf, extra_extension_paths, extra_assets,
+ subprojects, **kwargs):
+ super().__init__(name, subdir, subproject, kwargs, absolute_paths=True)
+ self.hotdoc_conf = hotdoc_conf
+ self.extra_extension_paths = extra_extension_paths
+ self.extra_assets = extra_assets
+ self.subprojects = subprojects
+
+ def __getstate__(self):
+ # Make sure we do not try to pickle subprojects
+ res = self.__dict__.copy()
+ res['subprojects'] = []
+
+ return res
+
+
+class HotdocRunScript(build.RunScript):
+ def __init__(self, script, args):
+ super().__init__(script, args)
+
+
+class HotDocModule(ExtensionModule):
+ @FeatureNew('Hotdoc Module', '0.48.0')
+ def __init__(self, interpreter):
+ super().__init__(interpreter)
+ self.hotdoc = ExternalProgram('hotdoc')
+ if not self.hotdoc.found():
+ raise MesonException('hotdoc executable not found')
+
+ try:
+ from hotdoc.run_hotdoc import run # noqa: F401
+ self.hotdoc.run_hotdoc = run
+ except Exception as e:
+ raise MesonException('hotdoc %s required but not found. (%s)' % (
+ MIN_HOTDOC_VERSION, e))
+
+ @noKwargs
+ def has_extensions(self, state, args, kwargs):
+ res = self.hotdoc.run_hotdoc(['--has-extension'] + args) == 0
+ return ModuleReturnValue(res, [res])
+
+ def generate_doc(self, state, args, kwargs):
+ if len(args) != 1:
+ raise MesonException('One positional argument is'
+ ' required for the project name.')
+
+ project_name = args[0]
+ builder = HotdocTargetBuilder(project_name, state, self.hotdoc, kwargs)
+ target, install_script = builder.make_targets()
+ targets = [HotdocTargetHolder(target, self.interpreter)]
+ if install_script:
+ targets.append(install_script)
+
+ return ModuleReturnValue(targets[0], targets)
+
+
+def initialize(interpreter):
+ return HotDocModule(interpreter)
diff --git a/mesonbuild/scripts/hotdochelper.py b/mesonbuild/scripts/hotdochelper.py
new file mode 100644
index 000000000..826745d09
--- /dev/null
+++ b/mesonbuild/scripts/hotdochelper.py
@@ -0,0 +1,36 @@
+import os
+import shutil
+import subprocess
+
+from . import destdir_join
+
+import argparse
+
+parser = argparse.ArgumentParser()
+parser.add_argument('--install')
+parser.add_argument('--extra-extension-path', action="append", default=[])
+parser.add_argument('--name')
+parser.add_argument('--builddir')
+parser.add_argument('--project-version')
+
+
+def run(argv):
+ options, args = parser.parse_known_args(argv)
+ subenv = os.environ.copy()
+
+ for ext_path in options.extra_extension_path:
+ subenv['PYTHONPATH'] = subenv.get('PYTHONPATH', '') + ':' + ext_path
+
+ res = subprocess.call(args, cwd=options.builddir, env=subenv)
+ if res != 0:
+ exit(res)
+
+ if options.install:
+ source_dir = os.path.join(options.builddir, options.install)
+ destdir = os.environ.get('DESTDIR', '')
+ installdir = destdir_join(destdir,
+ os.path.join(os.environ['MESON_INSTALL_PREFIX'],
+ 'share/doc/', options.name, "html"))
+
+ shutil.rmtree(installdir, ignore_errors=True)
+ shutil.copytree(source_dir, installdir)
diff --git a/test cases/frameworks/23 hotdoc/doc/index.md b/test cases/frameworks/23 hotdoc/doc/index.md
new file mode 100644
index 000000000..ea5eeb106
--- /dev/null
+++ b/test cases/frameworks/23 hotdoc/doc/index.md
@@ -0,0 +1 @@
+# Hello world!
diff --git a/test cases/frameworks/23 hotdoc/doc/meson.build b/test cases/frameworks/23 hotdoc/doc/meson.build
new file mode 100644
index 000000000..a09bff08d
--- /dev/null
+++ b/test cases/frameworks/23 hotdoc/doc/meson.build
@@ -0,0 +1,19 @@
+hotdoc = import('hotdoc')
+
+target = hotdoc.generate_doc(
+ 'foobar',
+ c_smart_index: true,
+ project_version: '0.1',
+ sitemap: 'sitemap.txt',
+ index: 'index.md',
+ c_sources: files('../../10 gtk-doc/include/foo.h'),
+ languages: ['c'],
+ install: true,
+)
+
+assert(target.config_path() == target.full_path() + '.json',
+ 'Hotdoc config paths do not match.'
+)
+
+assert(hotdoc.has_extensions('search') == true,
+ 'Extension "search" provided by hotdoc core should always be found')
diff --git a/test cases/frameworks/23 hotdoc/doc/sitemap.txt b/test cases/frameworks/23 hotdoc/doc/sitemap.txt
new file mode 100644
index 000000000..b82354a1d
--- /dev/null
+++ b/test cases/frameworks/23 hotdoc/doc/sitemap.txt
@@ -0,0 +1,3 @@
+index.md
+ c-index
+
diff --git a/test cases/frameworks/23 hotdoc/meson.build b/test cases/frameworks/23 hotdoc/meson.build
new file mode 100644
index 000000000..191569dd6
--- /dev/null
+++ b/test cases/frameworks/23 hotdoc/meson.build
@@ -0,0 +1,9 @@
+project('hotdoc', 'c')
+
+hotdoc = find_program('hotdoc', required: false)
+if not hotdoc.found()
+ error('MESON_SKIP_TEST hotdoc not found.')
+endif
+
+subdir('doc')
+