diff --git a/docs/conf.py b/docs/conf.py index 1c0ebf2..c5dc42f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -124,3 +124,26 @@ # CUSTOM CONFIGURATION intersphinx_mapping['render'] = ('https://sphinx.silverrainz.me/render/', None) + +# [example config start] +data_define_directives = { + 'cat': { + 'schema': { + 'name': 'str, required', + 'attrs': { + 'color': 'str', + }, + 'content': 'str, required' + }, + 'template': { + 'debug': False, + 'on': 'parsing', + 'text': '\n'.join([ + 'Hi human! I am a cat named {{ name }}, I have {{ color }} fur.', + '', + '{{ content }}.', + ]), + }, + }, +} +# [example config end] diff --git a/docs/conf.rst b/docs/conf.rst new file mode 100644 index 0000000..0fe36a1 --- /dev/null +++ b/docs/conf.rst @@ -0,0 +1,30 @@ +============= +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, works same as the + :rst:dir:`data.schema` directive, which has the following keys: + + - ``name`` (str, optional): same as the directive argument + - ``attr`` (dict, can be empty): same as the directive options + - ``content`` (str, optional): same as the directive content + + - ``template`` (dict): Template definition, works same as the + :rst:dir:`data.template` directive, which has the following keys: + + - ``text`` (str): the Jinja2 template text. + - ``on`` (str, optional): same as :rst:dir:`data.template:on` + - ``debug`` (bool, optional): same as :rst:dir:`data.template:debug` + + See :ref:`custom-dir` for example. + 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..6bd8942 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -118,3 +118,41 @@ Directives .. data.render:: Sphinx has **{{ _sphinx.extensions | length }}** extensions loaded. + + +.. _custom-dir: + +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. + +First, add ``'sphinxnotes.data'`` to your extension list like what we do in +:doc:`Getting Started `. + +Then add the following code to your :file:`conf.py`: + +.. literalinclude:: conf.py + :language: python + :start-after: [example config start] + :end-before: [example config end] + +This creates a ``.. cat::`` directive that requires a name argument and accepts +a ``color`` options and a content. Use it in your document: + +.. example:: + :style: grid + + .. cat:: mimi + :color: black and brown + + I like fish! + +For more details please refer to the :confval:`data_define_directives` +configuration value. 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”.'