diff --git a/docs/conf.rst b/docs/conf.rst new file mode 100644 index 0000000..3a17cd8 --- /dev/null +++ b/docs/conf.rst @@ -0,0 +1,48 @@ +============= +Configuration +============= + +The extension provides the following configuration: + +.. autoconfval:: data_define_directives + + A dictionary ``dict[str, directive_def]`` for creating custom directives for + data definition. + + The ``str`` key is the name of the directive to be created; + The ``directive_def`` value is a ``dict`` with the following keys: + + - ``schema`` (dict): Schema definition, with keys: + + - ``name`` (str, optional): The DSL for the name field. Defaults to + ``'str, required, uniq, ref'``. + - ``attrs`` (dict, optional): A mapping of attribute names to DSL. + Defaults to ``{}``. + - ``content`` (str, optional): The DSL for content. Defaults to ``'str'``. + + - ``template`` (dict): Template definition, with keys: + + - ``text`` (str, required): The Jinja2 template text. + - ``on`` (str, optional): The render phase. Defaults to ``'parsing'``. + Available values: ``'parsing'``, ``'parsed'``, ``'resolving'``. + - ``debug`` (bool, optional): Enable debug output. Defaults to ``False``. + + Example: + + .. code-block:: python + + data_define_directives = { + 'custom': { + 'schema': { + 'name': 'str, required', + 'attrs': {'type': 'str'}, + }, + 'template': { + 'on': 'parsing', + 'text': 'Custom: {{ name }} (type: {{ attrs.type }})', + }, + }, + } + + This creates a ``.. custom::`` directive that requires a name argument + and accepts a ``type`` option. diff --git a/docs/index.rst b/docs/index.rst index 7908f62..9f90ede 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -109,6 +109,7 @@ Contents :caption: Contents usage + conf changelog The Sphinx Notes Project diff --git a/docs/usage.rst b/docs/usage.rst index 624579c..211be27 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -118,3 +118,85 @@ Directives .. data.render:: Sphinx has **{{ _sphinx.extensions | length }}** extensions loaded. + + +Defining Custom Directives +=========================== + +Instead of using :rst:dir:`data.define`, :rst:dir:`data.template`, and +:rst:dir:`data.schema` directives to define data in documents, you can define +custom directives in :file:`conf.py` using the :confval:`data_define_directives` +configuration option. + +This is useful when you want to create a reusable directive with a fixed schema +and template across multiple documents. + +Basic Usage +----------- + +First, configure the directive in your :file:`conf.py`: + +.. code-block:: python + + extensions = ['sphinxnotes.data'] + + data_define_directives = { + 'cat': { + 'schema': { + 'name': 'str, required', + 'attrs': {'color': 'str', 'age': 'int'}, + }, + 'template': { + 'text': '{{ name }} is a {{ attrs.color }} cat, {{ attrs.age }} years old.', + }, + }, + } + +Then use it in your document: + +.. code-block:: rst + + .. cat:: mimi + :color: black + :age: 3 + +This will render: **mimi is a black cat, 3 years old.** + +Render Phase +------------ + +You can specify when the template is rendered using the ``on`` option: + +.. code-block:: python + + data_define_directives = { + 'delayed': { + 'schema': {'name': 'str'}, + 'template': { + 'on': 'resolving', + 'text': 'Document: {{ name }}', + }, + }, + } + +See :external+render:ref:`phases` for more details about render phases. + +Debug Output +------------ + +You can enable debug output for template rendering using the ``debug`` option: + +.. code-block:: python + + data_define_directives = { + 'debug example': { + 'schema': {'name': 'str'}, + 'template': { + 'text': '{{ name }}', + 'debug': True, + }, + }, + } + +When enabled, the directive will display debug information about the template +rendering process in the generated documentation. diff --git a/pyproject.toml b/pyproject.toml index 27960a1..ad000ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,7 @@ dependencies = [ # CUSTOM DEPENDENCIES START "sphinxnotes-render", + "schema", # python dict schema validation # CUSTOM DEPENDENCIES END ] diff --git a/src/sphinxnotes/data/__init__.py b/src/sphinxnotes/data/__init__.py index 3296d8f..ffbd762 100644 --- a/src/sphinxnotes/data/__init__.py +++ b/src/sphinxnotes/data/__init__.py @@ -19,13 +19,6 @@ from typing import TYPE_CHECKING from . import meta -from .adhoc import ( - TemplateDefineDirective, - SchemaDefineDirective, - FreeDataDefineDirective, - FreeDataDefineRoleDispatcher, - DataRenderDirective, -) if TYPE_CHECKING: from sphinx.application import Sphinx @@ -36,11 +29,9 @@ def setup(app: Sphinx): app.setup_extension('sphinxnotes.render') - app.add_directive('data.define', FreeDataDefineDirective) - app.add_directive('data.template', TemplateDefineDirective) - app.add_directive('data.schema', SchemaDefineDirective) - app.add_directive('data.render', DataRenderDirective) + from . import adhoc, derive - app.connect('source-read', FreeDataDefineRoleDispatcher().install) + adhoc.setup(app) + derive.setup(app) return meta.post_setup(app) diff --git a/src/sphinxnotes/data/adhoc.py b/src/sphinxnotes/data/adhoc.py index a8d52c0..5cacb50 100644 --- a/src/sphinxnotes/data/adhoc.py +++ b/src/sphinxnotes/data/adhoc.py @@ -33,7 +33,7 @@ from types import ModuleType from docutils.utils import Reporter from sphinx.util.typing import RoleFunction - from sphinxnotes.render.ctx import PendingContext, ResolvedContext + from sphinxnotes.render import PendingContext, ResolvedContext # Keys of env.temp_data. @@ -201,3 +201,12 @@ def install(self, app: Sphinx, docname: str, source: list[str]) -> None: automatically. """ self.enable() + + +def setup(app: Sphinx) -> None: + app.add_directive('data.define', FreeDataDefineDirective) + app.add_directive('data.template', TemplateDefineDirective) + app.add_directive('data.schema', SchemaDefineDirective) + app.add_directive('data.render', DataRenderDirective) + + app.connect('source-read', FreeDataDefineRoleDispatcher().install) diff --git a/src/sphinxnotes/data/derive.py b/src/sphinxnotes/data/derive.py new file mode 100644 index 0000000..3d4ad95 --- /dev/null +++ b/src/sphinxnotes/data/derive.py @@ -0,0 +1,69 @@ +""" +sphinxnotes.data.derive +~~~~~~~~~~~~~~~~~~~~~~~ + +:copyright: Copyright 2025~2026 by the Shengyu Zhang. +:license: BSD, see LICENSE for details. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from schema import Schema as DictSchema, SchemaError as DictSchemaError, Optional, Or + +from sphinx.errors import ConfigError +from sphinxnotes.render import Schema, Template, Phase, StrictDataDefineDirective + +if TYPE_CHECKING: + from sphinx.application import Sphinx + from sphinx.config import Config + + +DATA_DEFINE_DIRECTIVE = DictSchema( + { + 'schema': { + Optional('name', default='str, required, uniq, ref'): Or(str, type(None)), + Optional('attrs', default={}): {str: str}, + Optional('content', default='str'): Or(str, type(None)), + }, + 'template': { + Optional('on', default='parsing'): Or('parsing', 'parsed', 'resolving'), + 'text': str, + Optional('debug', default=False): bool, + }, + } +) + + +def _validate_directive_define(d: dict, config: Config) -> tuple[Schema, Template]: + validated = DATA_DEFINE_DIRECTIVE.validate(d) + + schemadef = validated['schema'] + schema = Schema.from_dsl( + schemadef['name'], schemadef['attrs'], schemadef['content'] + ) + + tmpldef = validated['template'] + phase = Phase[tmpldef['on'].title()] + template = Template(text=tmpldef['text'], phase=phase, debug=tmpldef['debug']) + + return schema, template + + +def _config_inited(app: Sphinx, config: Config) -> None: + for name, objdef in app.config.data_define_directives.items(): + try: + schema, tmpl = _validate_directive_define(objdef, config) + except (DictSchemaError, ValueError) as e: + raise ConfigError( + f'Validating data_define_directives[{repr(name)}]: {e}' + ) from e + + directive_cls = StrictDataDefineDirective.derive(name, schema, tmpl) + app.add_directive(name, directive_cls) + + +def setup(app: Sphinx) -> None: + app.add_config_value('data_define_directives', {}, 'env', types=dict) + app.connect('config-inited', _config_inited) diff --git a/tests/roots/test-derive/conf.py b/tests/roots/test-derive/conf.py new file mode 100644 index 0000000..cf59d61 --- /dev/null +++ b/tests/roots/test-derive/conf.py @@ -0,0 +1,16 @@ +keep_warnings = True + +extensions = ['sphinxnotes.data'] + +data_define_directives = { + 'custom': { + 'schema': { + 'name': 'str, required', + 'attrs': {'type': 'str'}, + }, + 'template': { + 'on': 'parsing', + 'text': 'Custom: {{ name }} (type: {{ attrs.type }})', + }, + }, +} diff --git a/tests/roots/test-derive/index.rst b/tests/roots/test-derive/index.rst new file mode 100644 index 0000000..35549da --- /dev/null +++ b/tests/roots/test-derive/index.rst @@ -0,0 +1,8 @@ +Test +==== + +.. custom:: myname + :type: mytype + +.. custom:: myname + :unkown: diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 3735498..8359fc4 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -24,3 +24,14 @@ def test_data_template_and_define(app, status, warning, phase): assert 'RenderedValue1' in html assert 'RenderedValue2' in html assert 'RenderedContent' in html + + +@pytest.mark.sphinx('html', testroot='derive') +def test_data_define_directives(app, status, warning): + """Test that data_define_directives generates directives correctly.""" + app.build() + + html = (app.outdir / 'index.html').read_text(encoding='utf-8') + + assert 'Custom: myname (type: mytype)' in html + assert 'Error in “custom” directive: unknown option: “unkown”.'