diff --git a/.cruft.json b/.cruft.json index 652c944..001384f 100644 --- a/.cruft.json +++ b/.cruft.json @@ -8,7 +8,7 @@ "name": "render", "full_name": "sphinxnotes-render", "author": "Shengyu Zhang", - "description": "A framework to define, constrain, and render data in Sphinx documentation", + "description": "Define, constrain, and render data in Sphinx documentation", "version": "1.0a0", "github_owner": "sphinx-notes", "github_repo": "data", diff --git a/README.rst b/README.rst index a204011..1d96e74 100644 --- a/README.rst +++ b/README.rst @@ -20,7 +20,7 @@ sphinxnotes-render |docs| |license| |pypi| |download| -A framework to define, constrain, and render data in Sphinx documentation. +Define, constrain, and render data in Sphinx documentation. .. INTRODUCTION START (MUST written in standard reStructuredText, without Sphinx stuff) diff --git a/docs/api.rst b/docs/api.rst index 2e7d12b..c9e9742 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -2,39 +2,52 @@ API References ============== -The Render Pipeline -=================== +.. _api-directives: +.. _api-roles: -The pipeline defines how nodes carrying data are generated and when they are -rendered as part of the document. +Roles and Directives +==================== -1. Generation: :py:class:`~sphinxnotes.render.BaseContextRole`, - :py:class:`~sphinxnotes.render.BaseContextDirective` and their subclasses - create :py:class:`~sphinxnotes.render.pending_node` on document parsing, - and the node will be inserted to the document tree. The node contains: +.. seealso:: - - :ref:`context`, the dynamic content of a Jinja template + For a minimal end-to-end example of creating your own directive, start with + :ref:`ext-directives`. - - :py:class:`~sphinxnotes.render.Template`, - the Jinja template for rendering context to markup text - (reStructuredText or Markdown) +Base Role Classes +----------------- + +.. autoclass:: sphinxnotes.render.BaseContextRole + :show-inheritance: + :members: process_pending_node, queue_pending_node, queue_context, current_context, current_template + +.. autoclass:: sphinxnotes.render.BaseDataDefineRole + :show-inheritance: + :members: process_pending_node, queue_pending_node, queue_context, current_schema, current_template + +Base Directive Classes +---------------------- -2. Render: the ``pending_node`` node will be rendered at the appropriate - :py:class:`~sphinxnotes.render.Phase`, depending on - :py:attr:`~sphinxnotes.render.pending_node.template.phase`. +.. autoclass:: sphinxnotes.render.BaseContextDirective + :show-inheritance: + :members: process_pending_node, queue_pending_node, queue_context, current_raw_data, current_context, current_template -For a task-oriented explanation of template variables, extra context, and phase -selection, see :doc:`tmpl`. +.. autoclass:: sphinxnotes.render.BaseDataDefineDirective + :show-inheritance: + :members: process_pending_node, queue_pending_node, queue_context, current_raw_data, current_schema, current_template + +.. autoclass:: sphinxnotes.render.StrictDataDefineDirective + :show-inheritance: + :members: derive Node ------ +===== .. autoclass:: sphinxnotes.render.pending_node -.. _context: +.. _api-context: Context -------- +======= Context refers to the dynamic content of a Jinja template. It can be: @@ -57,7 +70,7 @@ Context refers to the dynamic content of a Jinja template. It can be: :members: resolve ``PendingContext`` Implementations -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +---------------------------------- .. autoclass:: sphinxnotes.render.UnparsedData :show-inheritance: @@ -65,7 +78,7 @@ Context refers to the dynamic content of a Jinja template. It can be: .. _extractx: Template --------- +======== See :doc:`tmpl` for the higher-level guide. @@ -76,12 +89,12 @@ See :doc:`tmpl` for the higher-level guide. :members: Extra Context -------------- +============= See :doc:`tmpl` for built-in extra-context names such as ``doc`` and ``sphinx``, plus usage examples. -.. autofunction:: sphinxnotes.render.extra_context +.. autodecorator:: sphinxnotes.render.extra_context .. autoclass:: sphinxnotes.render.ParsingPhaseExtraContext :members: phase, generate @@ -99,36 +112,10 @@ See :doc:`tmpl` for built-in extra-context names such as ``doc`` and :members: phase, generate :undoc-members: -Base Roles and Directives -------------------------- - -For a minimal end-to-end example of a custom directive, start with :doc:`usage`. - -Base Role Classes -~~~~~~~~~~~~~~~~~ - -.. autoclass:: sphinxnotes.render.BaseContextRole - :show-inheritance: - :members: process_pending_node, queue_pending_node, queue_context, current_context, current_template - -.. autoclass:: sphinxnotes.render.BaseDataDefineRole - :show-inheritance: - :members: process_pending_node, queue_pending_node, queue_context, current_schema, current_template - -Base Directive Classes -~~~~~~~~~~~~~~~~~~~~~~ +Filters +======= -.. autoclass:: sphinxnotes.render.BaseContextDirective - :show-inheritance: - :members: process_pending_node, queue_pending_node, queue_context, current_context, current_template - -.. autoclass:: sphinxnotes.render.BaseDataDefineDirective - :show-inheritance: - :members: process_pending_node, queue_pending_node, queue_context, current_schema, current_template - -.. autoclass:: sphinxnotes.render.StrictDataDefineDirective - :show-inheritance: - :members: derive +.. autodecorator:: sphinxnotes.render.filter Data, Field and Schema ====================== diff --git a/docs/conf.py b/docs/conf.py index eca90f5..602e690 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -122,23 +122,46 @@ sys.path.insert(0, os.path.abspath('../src/')) extensions.append('sphinxnotes.render') -extensions.append('sphinxnotes.data') - # CUSTOM CONFIGURATION +extensions.append('sphinxnotes.render.ext') + +# [example config start] +data_define_directives = { + 'cat': { + 'schema': { + 'name': 'str, required', + 'attrs': { + 'color': 'str', + }, + 'content': 'str, required' + }, + 'template': { + 'text': '\n'.join([ + 'Hi human! I am a cat named {{ name }}, I have {{ color }} fur.', + '', + '{{ content }}.', + ]), + }, + }, +} +# [example config end] + autodoc_default_options = { 'member-order': 'bysource', } intersphinx_mapping['python'] = ('https://docs.python.org/3', None) intersphinx_mapping['sphinx'] = ('https://www.sphinx-doc.org/en/master', None) -intersphinx_mapping['data'] = ('https://sphinx.silverrainz.me/data', None) -ctxdir_usage_example_path = os.path.abspath('../tests/roots/test-ctxdir-usage') +test_roots = os.path.abspath('../tests/roots') def setup(app): app.add_object_type('event', 'event') # for intersphinx - sys.path.insert(0, ctxdir_usage_example_path) - from conf import setup as setup_ctxdir_usage_example - setup_ctxdir_usage_example(app) + sys.path.insert(0, test_roots) + __import__('test-extra-context.conf').conf.setup(app) + __import__('test-base-context-directive-example.conf').conf.setup(app) + __import__('test-base-data-define-directive-example.conf').conf.setup(app) + __import__('test-strict-data-define-directive-example.conf').conf.setup(app) + __import__('test-filter-example.conf').conf.setup(app) 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/dsl.rst b/docs/dsl.rst index 96b0a02..8d5435f 100644 --- a/docs/dsl.rst +++ b/docs/dsl.rst @@ -180,61 +180,3 @@ DSL Input Result ``int, sep by ':'`` ``1:2:3`` :py:`[1, 2, 3]` =================== ========= ================ -Extending the FDL -================= - -You can extend the FDL by registering custom types, flags, and by-options -through the :attr:`~sphinxnotes.render.Registry.data` attribute of -:data:`sphinxnotes.render.REGISTRY`. - -.. _add-custom-types: - -Adding Custom Types -------------------- - -Use :meth:`~sphinxnotes.render.data.REGISTRY.add_type` method of -:data:`sphinxnotes.render.REGISTRY` to add a new type: - ->>> from sphinxnotes.render import REGISTRY ->>> ->>> def parse_color(v: str): -... return tuple(int(x) for x in v.split(';')) -... ->>> def color_to_str(v): -... return ';'.join(str(x) for x in v) -... ->>> REGISTRY.data.add_type('color', tuple, parse_color, color_to_str) ->>> Field.from_dsl('color').parse('255;0;0') -(255, 0, 0) - -.. _add-custom-flags: - -Adding Custom Flags -------------------- - -Use :meth:`~sphinxnotes.render.data.Registry.add_flag` method of -:data:`sphinxnotes.render.REGISTRY` to add a new flag: - ->>> from sphinxnotes.render import REGISTRY ->>> REGISTRY.data.add_flag('unique', default=False) ->>> field = Field.from_dsl('int, unique') ->>> field.unique -True - -.. _add-custom-by-options: - -Adding Custom By-Options ------------------------- - -Use :meth:`~sphinxnotes.render.data.Registry.add_by_option` method of -:data:`sphinxnotes.render.REGISTRY` to add a new by-option: - ->>> from sphinxnotes.render import REGISTRY ->>> REGISTRY.data.add_by_option('group', str) ->>> field = Field.from_dsl('str, group by size') ->>> field.group -'size' ->>> REGISTRY.data.add_by_option('index', str, store='append') ->>> field = Field.from_dsl('str, index by month, index by year') ->>> field.index -['month', 'year'] diff --git a/docs/ext.rst b/docs/ext.rst new file mode 100644 index 0000000..6368bc5 --- /dev/null +++ b/docs/ext.rst @@ -0,0 +1,252 @@ +========= +Extending +========= + +Extending the FDL +================= + +You can extend the :doc:`dsl` by registering custom types, flags, and by-options +through the :py:attr:`~sphinxnotes.render.Registry.data` attribute of +:py:data:`sphinxnotes.render.REGISTRY`. + +.. _add-custom-types: + +Adding Custom Types +------------------- + +Use :py:meth:`~sphinxnotes.render.data.REGISTRY.add_type` method of +:py:data:`sphinxnotes.render.REGISTRY` to add a new type: + +>>> from sphinxnotes.render import REGISTRY, Field +>>> +>>> def parse_color(v: str): +... return tuple(int(x) for x in v.split(';')) +... +>>> def color_to_str(v): +... return ';'.join(str(x) for x in v) +... +>>> REGISTRY.data.add_type('color', tuple, parse_color, color_to_str) +>>> Field.from_dsl('color').parse('255;0;0') +(255, 0, 0) + +.. _add-custom-flags: + +Adding Custom Flags +------------------- + +Use :py:meth:`~sphinxnotes.render.data.Registry.add_flag` method of +:py:data:`sphinxnotes.render.REGISTRY` to add a new flag: + +>>> from sphinxnotes.render import REGISTRY, Field +>>> REGISTRY.data.add_flag('unique', default=False) +>>> field = Field.from_dsl('int, unique') +>>> field.unique +True + +.. _add-custom-by-options: + +Adding Custom By-Options +------------------------ + +Use :py:meth:`~sphinxnotes.render.data.Registry.add_by_option` method of +:py:data:`sphinxnotes.render.REGISTRY` to add a new by-option: + +>>> from sphinxnotes.render import REGISTRY, Field +>>> REGISTRY.data.add_by_option('group', str) +>>> field = Field.from_dsl('str, group by size') +>>> field.group +'size' +>>> REGISTRY.data.add_by_option('index', str, store='append') +>>> field = Field.from_dsl('str, index by month, index by year') +>>> field.index +['month', 'year'] + +.. _ext-extra-context: + +Extending Extra Contexts +======================== + +Extra contexts are registered by a +:py:deco:`sphinxnotes.render.extra_context` class decorator. + +The decorated class must be one of the following classes: +:py:class:`~sphinxnotes.render.ParsingPhaseExtraContext`, +:py:class:`~sphinxnotes.render.ParsedPhaseExtraContext`, +:py:class:`~sphinxnotes.render.ResolvingPhaseExtraContext`, +:py:class:`~sphinxnotes.render.GlobalExtraContext`. + +.. literalinclude:: ../tests/roots/test-extra-context/conf.py + :language: python + :start-after: [literalinclude start] + :end-before: [literalinclude end] + +.. dropdown:: :file:`cat.json` + + .. literalinclude:: ../tests/roots/test-extra-context/cat.json + +.. example:: + :style: grid + + .. data.render:: + :extra: cat + + {{ load_extra('cat').name }} + +.. _ext-filters: + +Extending ilters +================= + +Template filters are registered by a +:py:deco:`sphinxnotes.render.filter` function decorator. + +The decorated function takes a :py:class:`sphinx.environment.BuildEnvironment` +as argument and returns a filter function. + +.. note:: + + The decorator is used to **decorate the filter function factory, NOT + the filter function itself**. + +.. literalinclude:: ../tests/roots/test-filter-example/conf.py + :language: python + :start-after: [literalinclude start] + :end-before: [literalinclude end] + +.. example:: + :style: grid + + .. data.render:: + + {{ "Hello world" | catify }} + +.. _ext-directives: +.. _ext-roles: + +Extending Directives/Roles +========================== + +.. tip:: + + Before reading this documentation, please refer to + :external+sphinx:doc:`development/tutorials/extending_syntax`. + See how to extend :py:class:`SphinxDirective` and :py:class:`SphinxRole`. + +All of the classes listed in :ref:`api-directives` are subclassed from the +internal ``sphinxnotes.render.Pipeline`` class, which is responsible to generate +the dedicated :py:class:`node ` that +carries a :ref:`context` and a :py:class:`~sphinxnotes.render.Template`. + +At the appropriate :ref:`render-phases`, the node will be rendered into markup +text, usually reStructuredText. The rendered text is then parsed again by +Sphinx and inserted into the document. + +.. seealso:: + + - :doc:`tmpl` for template variables, phases, and extra context + - :doc:`dsl` for the field description language used by + :py:class:`~sphinxnotes.render.Field` and + :py:class:`~sphinxnotes.render.Schema` + - Implementations of :parsed_literal:`sphinxnotes-render.ext__` + and :parsed_literal:`sphinxnotes-any__`. + + __ https://github.com/sphinx-notes/render/tree/master/src/sphinxnotes/render/ext + __ https://github.com/sphinx-notes/any + +Subclassing :py:class:`~sphinxnotes.render.BaseContextDirective` +---------------------------------------------------------------- + +Now we have a quick example to help you get Started. +:external+sphinx:doc:`Create a Sphinx documentation ` +with the following ``conf.py``: + +.. literalinclude:: ../tests/roots/test-base-context-directive-example/conf.py + +This is the smallest useful extension built on top of ``sphinxnotes.render``: + +- it defines a mimi-dedicated directive by subclassing + :py:class:`~sphinxnotes.render.BaseContextDirective` +- it returns a :py:class:`~sphinxnotes.render.ResolvedContext` object from + ``current_context()`` +- it returns a :py:class:`~sphinxnotes.render.Template` from + ``current_template()`` +- the template is rendered in the default + :py:data:`~sphinxnotes.render.Phase.Parsing` phase + +Now use the directive in your document: + +.. example:: + :style: grid + + .. mimi:: + +Subclassing :py:class:`~sphinxnotes.render.BaseDataDefineDirective` +------------------------------------------------------------------- + +``BaseDataDefineDirective`` is higher level of API than ``BaseContextDirective``. +You no longer need to implement the ``current_context`` methods; instead, +implement the :py:meth:`~sphinxnotes.render.BaseDataDefineDirective.current_schema` +method. + +Here's an example: + +.. literalinclude:: ../tests/roots/test-base-data-define-directive-example/conf.py + +Key differences from ``BaseContextDirective``: + +- The directive automatically generates :py:class:`~sphinxnotes.render.RawData` + (from directive's arguments, options, and content, by method + :py:meth:`~sphinxnotes.render.BaseDataDefineDirective.current_raw_data`). +- The generated RawData are parsed to :py:class:`~sphinxnotes.render.ParsedData` + according to the :py:class:`~sphinxnotes.render.Schema` returned from + :py:meth:`~sphinxnotes.render.BaseDataDefineDirective.current_schema` method. + + .. tip:: + + Internally, the ``ParsedData`` is returned by ``current_context``, so + we do not need to implement it. + +- The the fields of schema are generated from :doc:`dsl` which restricted the + ``color`` must be an space-separated list, and ``birth`` must be a integer. +- The ``current_template`` still returns a Jinja template, but it uses more fancy + syntax. + +Use the directive in your document: + +.. example:: + :style: grid + + .. cat2:: mimi + :color: black and brown + :birth: 2025 + + I like fish! + +Subclassing :py:class:`~sphinxnotes.render.StrictDataDefineDirective` +---------------------------------------------------------------------- + +``StrictDataDefineDirective`` is an even higher-level API built on top of +``BaseDataDefineDirective``. It automatically handles ``SphinxDirective``'s members +from your :py:class:`~sphinxnotes.render.Schema`, so you don't need to manually +set: + +- ``required_arguments`` / ``optional_arguments`` - derived from ``Schema.name`` +- ``option_spec`` - derived from ``Schema.attrs`` +- ``has_content`` - derived from ``Schema.content`` + +You no longer need to manually create subclasses, simply pass ``schema`` and +``template`` to :py:meth:`~sphinxnotes.render.StrictDataDefineDirective.derive` +method: + +.. literalinclude:: ../tests/roots/test-strict-data-define-directive-example/conf.py + +Use the directive in your document: + +.. example:: + :style: grid + + .. cat3:: mimi + :color: black and brown + :birth: 2025 + + I like fish! diff --git a/docs/index.rst b/docs/index.rst index 80d8608..21b7681 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -28,27 +28,113 @@ Introduction .. INTRODUCTION START -A framework to define, constrain, and render data in Sphinx documentation. +This extension mainly consists of two parts: + +:parsed_literal:`:ref:\`sphinxnotes.render.ext \`` + An extension built on top of this framework, allowing user to + :rst:dir:`define `, :rst:dir:`constrain ` and + :rst:dir:`render ` data entirely through the markup language. + +:parsed_literal:`:ref:\`sphinxnotes.render \`` + A framework to define, constrain, and render data in Sphinx documentation. .. INTRODUCTION END Getting Started =============== -.. ADDITIONAL CONTENT START +.. note:: + + In this section we discuss how to use the ``sphinxnotes.render.ext`` + extension. For the document to write your own extension, please refer to + :doc:`ext`. .. note:: - This extension is **aimed at advanced Sphinx users or extension developers**. + We assume you already have a Sphinx documentation, + if not, see `Getting Started with Sphinx`_. + + +First, downloading extension from PyPI: + +.. code-block:: console + + $ pip install "sphinxnotes-render[ext]" + + +Then, add the extension name to ``extensions`` configuration item in your +:parsed_literal:`conf.py_`: + +.. code-block:: python + + extensions = [ + # … + 'sphinxnotes.render.ext', + # … + ] + +.. _Getting Started with Sphinx: https://www.sphinx-doc.org/en/master/usage/quickstart.html +.. _conf.py: https://www.sphinx-doc.org/en/master/usage/configuration.html + +.. ADDITIONAL CONTENT START + +We need to create a template to tell extension how to render the data. +The extension provides two ways for this: + +Way 1: by Directive +------------------- + +The :rst:dir:`data.template` directive will not change the content document, +it creates and stashes a temporary template for later use: + +.. code:: rst + + .. data.template:: + + Hi human! I am a cat named {{ name }}, I have {{ color }} fur. - If you are new to Sphinx, you may instersting with - :parsed_literal:`sphinxnotes.data__` or :parsed_literal:`sphinxnotes.any__`. + {{ content }}. - __ https://sphinx.silverrainz.me/data - __ https://sphinx.silverrainz.me/any +.. data.template:: -We cannot get you started with this short section; please refer to the -:doc:`usage` for more detailed information. + Hi human! I am a cat named {{ name }}, I have {{ color }} fur. + + {{ content }}. + +Now we can define data, using a :rst:dir:`data.define` directive: + +.. example:: + :style: grid + + .. data.define:: mimi + :color: black and brown + + I like fish! + +Please refer to :ref:`ext-usage-directives` for more details. + +Way 2: by Configuration +----------------------- + +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`` option and a content block. Use it in your document: + +.. example:: + :style: grid + + .. cat:: mimi + :color: black and brown + + I like fish! + +Please refer to :confval:`data_define_directives` for more details. .. ADDITIONAL CONTENT END @@ -57,13 +143,26 @@ Contents .. toctree:: :caption: Contents - :maxdepth: 2 + + changelog + +.. toctree:: + :name: extension + :caption: Extension + :maxdepth: 1 usage + conf + +.. toctree:: + :name: framework + :caption: Framework + :maxdepth: 1 + tmpl + ext dsl api - changelog The Sphinx Notes Project ======================== diff --git a/docs/tmpl.rst b/docs/tmpl.rst index 0734eef..6f14db5 100644 --- a/docs/tmpl.rst +++ b/docs/tmpl.rst @@ -2,76 +2,70 @@ Templating ========== -This page focuses on the context made available to templates, when that context -is available, and how extension authors can add more of it. You should already -be comfortable with basic Jinja2 syntax before reading this page. +This guide explains how to write Jinja2_ templates for the +``sphinxnotes.render``-based extension (``sphinxnotes.render.ext``, +``sphinxnotes.any`` and etc.). You should already be comfortable with basic +Jinja2 syntax before reading this page. .. _Jinja2: https://jinja.palletsprojects.com/en/stable/templates/ -Jinja Environment -================= - -Templates are rendered in a sandboxed Jinja2 environment. - -- Undefined variables raise errors by default (``undefined=DebugUndefined``) -- Extension ``jinja2.ext.loopcontrols``, ``jinja2.ext.do`` are enabled by default. -- Output is plain markup text, so you can generate lists, directives, roles, - and other reStructuredText constructs. +What is a Template +================== -Built-in filters ----------------- +A template is a Jinja2 text that defines how structured data is converted into +reStructuredText or Markdown markup. The rendered text is then parsed by Sphinx +and inserted into the document. -``role`` - We provides a ``roles`` filter for producing role markup from a sequence of - strings. +The way of defining template will vary depending on the extension you use. +For ``sphinxnotes.render.ext``, you can use :rst:dir:`data.template` or +:confval:`data_define_directives`. - .. example:: - :style: grid +.. tip:: - .. data.render:: + Internally, template is a :py:class:`~sphinxnotes.render.Template` object. + It is provide by method + :py:meth:`BaseDataDefineDirective.current_template() ` + or + :py:meth:`BaseDataDefineRole.current_template() ` - {% - set text = ['index', 'usage'] - | roles('doc') - | join(', ') - %} - :Text: ``{{ text }}`` - :Rendered: {{ text }} +What Data is Available +====================== -Extending filters ------------------ +Your template receives data from two sources: **main context** and **extra +context**. -To be done. +.. _context: -Context -======= +Main Context +------------ -.. _data-context: +When you define data through a directive (such as :rst:dir:`data.define`) or +role in your document, the template receives that data as its main context. +This is the data explicitly provided by the markup itself. -Directive and Role Context --------------------------- +For example, when you use the ``data.define`` directive, the generated main +context looks like the Python dict on the right: -When a directive or role provides data through -:py:class:`~sphinxnotes.render.BaseDataDefineDirective` or -:py:class:`~sphinxnotes.render.BaseDataDefineRole`, the template receives a -:py:class:`~sphinxnotes.render.ParsedData` object as its main context. +.. example:: + :style: grid -The following `template variables`_ are available in the main context: + .. data.define:: mimi + :color: black and brown -.. _template variables: https://jinja.palletsprojects.com/en/stable/templates/#variables + I like fish! -.. note:: +The template receives the argument (``mimi``), options (``:color: black ...``), +and body content (``I like fish!``) as the main context. - We use the :rst:dir:`data.template` and :rst:dir:`data.define` directives from - :parsed_literal:`sphinxnotes.data__` for exampling. +The following `template variables`_ are available: - __ https://sphinx.silverrainz.me/data +.. _template variables: https://jinja.palletsprojects.com/en/stable/templates/#variables .. glossary:: ``{{ name }}`` - For directive, this refer to the directive argument. + For directives, this refers to the directive argument. .. example:: :style: grid @@ -82,12 +76,12 @@ The following `template variables`_ are available in the main context: .. data.define:: This is the argument - For role, this is not available for now. + For roles, this is not available. - ``{{ attrs.xxx }}`` - For directive, this refer to the directive options. - It is a mapping of option's field to its value, so - ``{{ attrs.label }}`` and ``{{ attrs['label'] }}`` are equivalent. + ``{{ attrs }}`` + For directives, this refers to directive options. It is a mapping of + option field to value, so ``{{ attrs.label }}`` and + ``{{ attrs['label'] }}`` are equivalent. .. example:: :style: grid @@ -99,27 +93,24 @@ The following `template variables`_ are available in the main context: .. data.define:: :label: Important - For role, this is not available for now. - - .. note:: + For roles, this is not available. - Attribute values are also lifted to the top-level template context when - there is no name conflict. For example, ``{{ label }}`` can be used - instead of ``{{ attrs.label }}``, but ``{{ name }}`` still refers to - the data object's own ``name`` field. + Attribute values are lifted to the top-level template context when there + is no name conflict. For example, ``{{ label }}`` can be used instead of + ``{{ attrs.label }}``: - .. example:: - :style: grid + .. example:: + :style: grid - .. data.template:: + .. data.template:: - {{ label }} and {{ attrs.label }} are same. + Label is {{ label }}. - .. data.define:: - :label: Important + .. data.define:: + :label: Important ``{{ content }}`` - For directive, this refer to the directive body. + For directives, this refers to the directive body. .. example:: :style: grid @@ -132,7 +123,7 @@ The following `template variables`_ are available in the main context: This is the body content. - For role, this refer to the interpreted text. + For roles, this refers to the interpreted text. .. example:: :style: grid @@ -143,104 +134,197 @@ The following `template variables`_ are available in the main context: :data:`This is the interpreted text` -The type of each variable depends on the corresponding :py:class:`~sphinxnote.render.Schema`. -For developers, the schema is provided by the -:py:meth:`sphinxnotes.render.BaseDataDefineDirective.current_schema` method. -For users, different extensions define the schema differently. -For example, for the `sphinxnotes.data` extension, the schema is defined through -the :rst:dir:`data.schema` directive. +The type of each variable depends on the corresponding schema. Different +extensions define schemas differently. For example, the ``sphinxnotes.render.ext`` +extension defines the schema through the :rst:dir:`data.schema` directive or +``schema`` field of :confval:`data_define_directives`. + +.. tip:: + + Internally, Main context is a :py:class:`~sphinxnotes.render.ParsedData` + object. + + Directive or role subclassed from + :py:class:`~sphinxnotes.render.BaseDataDefineDirective` or + :py:class:`~sphinxnotes.render.BaseDataDefineRole` can generate main context. + +.. _extra-context: Extra Context ------------- -Templates can access additional context through **extra context**. Extra context -must be explicitly declared using the :rst:dir:`templat:extra` option and loaded in the -template using the ``load_extra()`` function. +Extra context provides access to pre-prepared structured data from external +sources (such as Sphinx application, JSON file, and etc.). Unlike main context +which comes from the directive/role itself, extra context lets you fetch data +that was prepared beforehand. + +Extra contexts are typically generated on demand at different construction stages, +so you need to declare them in advance, and load it in the template using the +``load_extra()`` function: + +The way of declaring extra context is vary depending on the extension you use. +For ``sphinxnotes.render.ext`` extension, :rst:dir:`data.template:extra`, +:rst:dir:`data.render:extra` and the ``templat.extra`` field of +:confval:`data_define_directives` are for this. + +.. example:: + :style: grid + + .. data.render:: + :extra: doc + + {% set doc = load_extra('doc') %} + + Document Title: "{{ doc.title }}" Built-in Extra Contexts ~~~~~~~~~~~~~~~~~~~~~~~ +The following extra contexts are available: + ``sphinx`` -.......... + :Phase: all + + A proxy to the :py:class:`sphinx.application.Sphinx` object. -:Phase: all + .. example:: + :style: grid + + .. data.render:: + :extra: sphinx -A proxy to the :py:class:`sphinx.application.Sphinx` object. + {% set app = load_extra('sphinx') %} + + **{{ app.extensions | length }}** + extensions are loaded. ``env`` -....... + :Phase: all + + A proxy to the :py:class:`sphinx.environment.BuildEnvironment` object. + + .. example:: + :style: grid + + .. data.render:: + :extra: env -:Phase: all + {% set env = load_extra('env') %} -A proxy to the :py:class:`sphinx.environment.BuildEnvironment` object. + **{{ env.all_docs | length }}** + documents found. ``markup`` -.......... + :Phase: parsing and later -:Phase: :term:`parsing` and later + Information about the current directive or role invocation, such as its + type, name, source text, and line number. -Information about the current directive or role invocation, such as its -type, name, source text, and line number. + .. example:: + :style: grid + .. data.render:: + :extra: markup + + {% + set m = load_extra('markup') + | jsonify + %} + + .. code:: + + {% for line in m.split('\n') -%} + {{ line }} + {% endfor %} + ``section`` -............ + :Phase: parsing and later + + A proxy to the current :py:class:`docutils.nodes.section` node, when one + exists. + + .. example:: + :style: grid -:Phase: :term:`parsing` and later + .. data.render:: + :extra: section -A proxy to the current :py:class:`docutils.nodes.section` node, when one exists. + Section Title: + "{{ load_extra('section').title }}" ``doc`` -....... + :Phase: parsing and later -:Phase: :term:`parsing` and later + A proxy to the current :py:class:`docutils.notes.document` node. -A proxy to the current :py:class:`docutils.notes.document` node. + .. example:: + :style: grid -.. example:: - :style: grid + .. data.render:: + :extra: doc - .. data.render:: - :extra: doc + Document title: + "{{ load_extra('doc').title }}". +.. seealso:: :ref:`ext-extra-context` - Title of current document is - "{{ load_extra('doc').title }}". +TODO: the proxy object. -Extending extra context -....................... +Built-in Filters +================ -Extension authors can register custom extra context using the -:py:func:`~sphinxnotes.render.extra_context` decorator. +In addition to the `Builtin Jinia Filters`__, this extension also provides the +following filters: -.. code-block:: python +__ https://jinja.palletsprojects.com/en/stable/templates/#builtin-filters - from sphinxnotes.render import extra_context, ParsingPhaseExtraContext +``roles`` + Produces role markup from a sequence of strings. - @extra_context('custom') - class CustomExtraContext(ParsingPhaseExtraContext): - def generate(self, directive): - return {'info': 'custom data'} + .. example:: + :style: grid + + .. data.render:: -Template -======== + {% + set text = ['index', 'usage'] + | roles('doc') + | join(', ') + %} -.. _phases: + :Text: ``{{ text }}`` + :Rendered: {{ text }} + +``jsonify`` + Convert value to JSON. + + .. example:: + :style: grid + + .. data.render:: + + {% set text = {'name': 'mimi'} %} + + :Strify: ``{{ text }}`` + :JSONify: ``{{ text | jsonify }}`` + +.. seealso:: :ref:`ext-filters` + +.. _render-phases: Render Phases -------------- +============= -Each :py:class:`~sphinxnotes.render.Template` has a render phase controlled by -:py:class:`~sphinxnotes.render.Phase`. +Each template has a render phase that determines when it is processed: .. glossary:: ``parsing`` - Corresponding to :py:data:`sphinxnotes.render.Phase.Parsing`. - Render immediately while the directive or role is running. + Render immediately while the directive or role is running. This is the + default. - This is the default render phase. - Choose this when the template only needs local information and does not rely - on the final doctree or cross-document state. + Choose this when the template only needs local information and does not + rely on the final doctree or cross-document state. .. example:: :style: grid @@ -260,7 +344,6 @@ Each :py:class:`~sphinxnotes.render.Template` has a render phase controlled by document(s). ``parsed`` - Corresponding to :py:data:`sphinxnotes.render.Phase.Parsed`. Render after the current document has been parsed. Choose this when the template needs the complete doctree of the current @@ -284,12 +367,11 @@ Each :py:class:`~sphinxnotes.render.Template` has a render phase controlled by document(s). ``resolving`` - Corresponding to :py:data:`sphinxnotes.render.Phase.Resolving`. Render late in the build, after references and other transforms are being resolved. - Choose this when the template depends on pr - structure that is only stable near the end of the pipeline. + Choose this when the template depends on the document structure that is + only stable near the end of the pipeline. .. example:: :style: grid @@ -308,54 +390,36 @@ Each :py:class:`~sphinxnotes.render.Template` has a render phase controlled by {{ env.all_docs | length }} document(s). -Debugging ---------- - -Set :py:attr:`sphinxnotes.render.Template.debug` to ``True`` to append a debug -report to the rendered document. The report includes the resolved context, -available extra-context keys, the template source, the rendered markup text, -and the final nodes produced from that markup. +.. tip:: -This is especially useful when a template fails because of an undefined -variable, unexpected data shape, or invalid generated markup. + Internally, each phase corresponds to a :py:class:`~sphinxnotes.render.Phase` + enum value. The ``on`` option maps to :py:attr:`~sphinxnotes.render.Template.phase`. -End-to-End Example -================== +.. _debug: -The following example shows a small custom directive that combines a schema and -template. The example is covered by a smoke test so the documentation stays in -sync with working code. - -Extension code: - -.. literalinclude:: ../tests/roots/test-strictdir-card/conf.py - :language: python - -Document source: - -.. literalinclude:: ../tests/roots/test-strictdir-card/index.rst - :language: rst - :lines: 4- +Debugging +========= -Rendered result: +Enable the debug option to see a detailed report when troubleshooting templates: -.. code-block:: rst +.. example:: + :style: grid - .. rubric:: Template Guide + .. data.render:: + :debug: - .. important:: Featured entry + {{ 1 + 1 }} - :Tags: jinja, docs +This is especially useful when a template fails due to an undefined variable, +unexpected data shape, or invalid generated markup. - This page explains the template context. +Some Technical Details +====================== -This pattern is often the most convenient way to build small, declarative -directives. For more control, subclass -:py:class:`~sphinxnotes.render.BaseDataDefineDirective` directly and implement -``current_schema()`` and ``current_template()`` yourself. - .. data.render:: - :extra: doc +Jinja Template +-------------- +Templates are rendered in a sandboxed Jinja2 environment. - Title of current document is - "{{ load_extra('doc').title }}". +- Undefined variables raise errors by default (``undefined=DebugUndefined``) +- Extension ``jinja2.ext.loopcontrols``, ``jinja2.ext.do`` are enabled by default. diff --git a/docs/usage.rst b/docs/usage.rst index a77dca4..49c1b2c 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -2,62 +2,164 @@ Usage ===== -.. seealso:: +This extension provides directives and roles for user to define, validate, and +render data. - Before reading this documentation, please refer to - :external+sphinx:doc:`development/tutorials/extending_syntax`. - See how to extend :py:class:`SphinxDirective` and :py:class:`SphinxRole`. +.. highlight:: rst -``sphinxnotes.render`` uses Jinja2_ to turn structured data into markup -text, usually reStructuredText. The rendered text is then parsed again by -Sphinx and inserted into the document. +.. _ext-usage-directives: -.. _Jinja2: https://jinja.palletsprojects.com/en/stable/templates/ +Directives +========== -Now we have a quick example to help you get Started. -Create a Sphinx documentation with the following ``conf.py``: +.. rst:directive:: .. data.define:: name -.. literalinclude:: ../tests/roots/test-ctxdir-usage/conf.py + Define data and render it inplace. -This is the smallest useful extension built on top of ``sphinxnotes.render``: + .. rst:directive:option:: * + :type: depends on the schema -- it defines a custom directive by subclassing - :py:class:`~sphinxnotes.render.BaseContextDirective` -- it returns a context object from ``current_context()`` -- it returns a :py:class:`~sphinxnotes.render.Template` from - ``current_template()`` -- the template is rendered in the default - :py:data:`~sphinxnotes.render.Phase.Parsing` phase + This directive uses the "freestyle" option spec, if no any + :rst:dir:`data.schema` applied, it allows arbitrary options to be specified. + Otherwise, the option name and value is depends on the schema. -Now use the directive in your document: + The directive generates a dict-like data structure, we call it + :ref:`context`, which looks like: -.. example:: - :style: grid + .. example:: + :style: grid - .. me:: - -Next steps -========== + .. data.define:: mimi + :color: black and brown + + I like fish! + + The fields of data context can be restricted and customized, see + :rst:dir:`data.schema` for details. + + The data will be rendered by template defined by the previous + :rst:dir:`data.template` directive. + +.. rst:directive:: data.template + + Define a template for rendering data. The template will be applied to + the subsequent :rst:dir:`data.define` directives. + Refer to :doc:`tmpl` for guide of writing template. + + .. rst:directive:option:: on + :type: choice + + Determinate :ref:`render-phases` of template. Defaults to ``parsing``. + Available values: ``['parsing', 'parsed', 'resolving']``. + + .. rst:directive:option:: debug + :type: flag + + Enable :ref:`debug report ` for template rendering. + + .. rst:directive:option:: extra + :type: space separted list + + List of :ref:`extra-context` to be used in the template. + + The content of the directive should be Jinja2 Template, please refer to + ::doc:`tmpl`. + + Example: + + .. example:: + :style: grid + + .. data.template:: + + Hi human! I am a cat named {{ name }}, I have {{ color }} fur. + + {{ content }}. + + .. data.define:: mimi + :color: black and brown + + I like fish! + + +.. rst:directive:: .. data.schema:: -Once you understand this minimal example, the rest of the workflow is usually: + Define a schema for restricting data. The schema will be applied to the + subsequent :rst:dir:`data.define` directives. + We use a custom Domain Specified Language (DSL) to descript field's type, + please refer to :doc:`dsl`. -1. define a :py:class:`~sphinxnotes.render.Schema` so your directive input is - parsed into structured values -2. write a Jinja template that consumes that structured context -3. choose an appropriate :py:class:`~sphinxnotes.render.Phase` when the - template needs document-level or project-level information -4. add custom extra context if built-in variables are not enough + .. rst:directive:option:: * + :type: -See also: + This directive uses the "freestyle" option spec, which allows arbitrary + options to be specified. Each option corresponding to an option of + :rst:dir:`data.define` directive. + + ``content: `` + + .. example:: + :style: grid + + .. data.schema:: int + + .. data.template:: + + ``{{ name }} + 1 = {{ name + 1 }}`` + + .. data.define:: 1 + +.. rst:directive:: data.render + + Render a template immediately without defining data. + This is useful when you want to render some fixed content or predefined data. + + .. rst:directive:option:: on + .. rst:directive:option:: debug + .. rst:directive:option:: extra + + The options of this directive are same to :rst:dir:`data.template`. + + .. example:: + :style: grid + + .. data.render:: + + ``1 + 1 = {{ 1 + 1 }}`` + +.. _usage-custom-directive: + +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.render.ext'`` 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 -- :doc:`tmpl` for template variables, phases, and extra context -- :doc:`dsl` for the field description language used by - :py:class:`~sphinxnotes.render.Field` and - :py:class:`~sphinxnotes.render.Schema` -- :doc:`api` for the full Python API + .. cat:: mimi + :color: black and brown -.. seealso:: See implementations of `sphinxnotes-any`__ and `sphinxnotes-data`__ - for more details + I like fish! - __ https://github.com/sphinx-notes/any - __ https://github.com/sphinx-notes/data +For more details please refer to the :confval:`data_define_directives` +configuration value. diff --git a/pyproject.toml b/pyproject.toml index 68db9d7..4b51b49 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ [project] name = "sphinxnotes-render" -description = "A framework to define, constrain, and render data in Sphinx documentation" +description = "Define, constrain, and render data in Sphinx documentation" readme = "README.rst" license = "BSD-3-Clause" license-files = ["LICENSE"] @@ -43,6 +43,7 @@ dependencies = [ "Sphinx >= 7.0", # CUSTOM DEPENDENCIES START + "Jinja2", # CUSTOM DEPENDENCIES END ] @@ -58,6 +59,7 @@ dev = [ ] test = [ "pytest", + "schema", # python dict schema validation ] docs = [ "furo", @@ -71,18 +73,21 @@ docs = [ # Dependencies of sphinxnotes projcts. "sphinxnotes-project", "sphinxnotes-comboroles", - "sphinxnotes-data", # CUSTOM DOCS DEPENDENCIES START + "schema", # python dict schema validation # CUSTOM DOCS DEPENDENCIES END ] +ext = [ + "schema", # python dict schema validation +] [project.urls] homepage = "https://sphinx.silverrainz.me/render" documentation = "https://sphinx.silverrainz.me/render" -repository = "https://github.com/sphinx-notes/data" +repository = "https://github.com/sphinx-notes/render" changelog = "https://sphinx.silverrainz.me/render/changelog.html" -tracker = "https://github.com/sphinx-notes/data/issues" +tracker = "https://github.com/sphinx-notes/render/issues" [build-system] requires = ["setuptools>=46.1.0", "setuptools_scm[toml]>=5", "wheel"] diff --git a/src/sphinxnotes/render/__init__.py b/src/sphinxnotes/render/__init__.py index 5d7d42e..ef98a23 100644 --- a/src/sphinxnotes/render/__init__.py +++ b/src/sphinxnotes/render/__init__.py @@ -21,20 +21,15 @@ Field, Schema, ) - -from .render import ( - Phase, - Template, - Host, -) +from .template import Phase, Template from .ctx import PendingContext, ResolvedContext from .ctxnodes import pending_node from .extractx import ( + extra_context, ParsingPhaseExtraContext, ParsedPhaseExtraContext, ResolvingPhaseExtraContext, GlobalExtraContext, - extra_context, ) from .pipeline import BaseContextRole, BaseContextDirective from .sources import ( @@ -43,6 +38,7 @@ BaseDataDefineDirective, StrictDataDefineDirective, ) +from .jinja import filter if TYPE_CHECKING: from sphinx.application import Sphinx @@ -60,7 +56,6 @@ 'Schema', 'Phase', 'Template', - 'Host', 'PendingContext', 'ResolvedContext', 'ParsingPhaseExtraContext', @@ -75,6 +70,7 @@ 'BaseDataDefineRole', 'BaseDataDefineDirective', 'StrictDataDefineDirective', + 'filter', ] @@ -92,10 +88,9 @@ def data(self) -> DataRegistry: def setup(app: Sphinx): meta.pre_setup(app) - from . import pipeline, extractx, template + from . import pipeline, jinja pipeline.setup(app) - extractx.setup(app) - template.setup(app) + jinja.setup(app) return meta.post_setup(app) diff --git a/src/sphinxnotes/render/ctxnodes.py b/src/sphinxnotes/render/ctxnodes.py index 7790f4e..6f1a4fc 100644 --- a/src/sphinxnotes/render/ctxnodes.py +++ b/src/sphinxnotes/render/ctxnodes.py @@ -5,7 +5,7 @@ from docutils import nodes from docutils.parsers.rst.states import Inliner -from .render import Template +from .template import Template from .ctx import ( PendingContextRef, PendingContext, @@ -13,7 +13,7 @@ ResolvedContext, ) from .markup import MarkupRenderer -from .template import TemplateRenderer +from .jinja import TemplateRenderer from .utils import ( Report, Reporter, diff --git a/src/sphinxnotes/render/ext/__init__.py b/src/sphinxnotes/render/ext/__init__.py new file mode 100644 index 0000000..8a30797 --- /dev/null +++ b/src/sphinxnotes/render/ext/__init__.py @@ -0,0 +1,32 @@ +""" +sphinxnotes.render.ext +~~~~~~~~~~~~~~~~~~~~~~ + +:copyright: Copyright 2026 by the Shengyu Zhang. +:license: BSD, see LICENSE for details. + +This is a POC (Proof of Concept) of the "sphinxnotes.render" extension. +""" + +from __future__ import annotations +from typing import TYPE_CHECKING + +from .. import meta + +if TYPE_CHECKING: + from sphinx.application import Sphinx + + +def setup(app: Sphinx): + meta.pre_setup(app) + + app.setup_extension('sphinxnotes.render') + + from . import adhoc, derive, extractx, filters + + adhoc.setup(app) + derive.setup(app) + extractx.setup(app) + filters.setup(app) + + return meta.post_setup(app) diff --git a/src/sphinxnotes/render/ext/adhoc.py b/src/sphinxnotes/render/ext/adhoc.py new file mode 100644 index 0000000..3644b85 --- /dev/null +++ b/src/sphinxnotes/render/ext/adhoc.py @@ -0,0 +1,221 @@ +""" +sphinxnotes.render.ext.adhoc +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:copyright: Copyright 2025~2026 by the Shengyu Zhang. +:license: BSD, see LICENSE for details. + +Provides directives and roles for temporarily rendering data within a document. + +""" + +from __future__ import annotations +from typing import TYPE_CHECKING, override, cast + +from docutils import nodes +from docutils.parsers.rst import directives +from sphinx.util.docutils import SphinxDirective, CustomReSTDispatcher + +from .. import ( + RawData, + Field, + Schema, + Phase, + Template, + BaseContextDirective, + BaseDataDefineDirective, + BaseDataDefineRole, +) +from ..utils.freestyle import FreeStyleDirective + +if TYPE_CHECKING: + from sphinx.application import Sphinx + from types import ModuleType + from docutils.utils import Reporter + from sphinx.util.typing import RoleFunction + from .. import PendingContext, ResolvedContext + + +# Keys of env.temp_data. +TEMPLATE_KEY = 'sphinxnotes.render.ext:template' +SCHEMA_KEY = 'sphinxnotes.render.ext:schema' + + +def phase_option_spec(arg): + choice = directives.choice(arg, [x.value for x in Phase]) + return Phase[choice.title()] + + +class TemplateDefineDirective(SphinxDirective): + option_spec = { + 'on': phase_option_spec, + 'debug': directives.flag, + 'extra': directives.unchanged, + } + has_content = True + + @override + def run(self) -> list[nodes.Node]: + extra_str = self.options.get('extra', '') + extra_list = extra_str.split() if extra_str else [] + + self.env.temp_data[TEMPLATE_KEY] = Template( + '\n'.join(self.content), + phase=self.options.get('on', Phase.default()), + debug='debug' in self.options, + extra=extra_list, + ) + + return [] + + @staticmethod + def directive_preset() -> Template: + return Template(""" +.. code:: py + + { + 'name': '{{ name }}', + 'attrs': {{ attrs }}, + 'content': '{{ content }}' + + # Lifted attrs + {% for k, v in attrs.items() -%} + '{{ k }}': '{{ v }}', + {%- endfor %} + }""") + + @staticmethod + def role_preset() -> Template: + return Template("""``{{ content or 'None' }}``""") + + +class SchemaDefineDirective(FreeStyleDirective): + optional_arguments = 1 + has_content = True + + def run(self) -> list[nodes.Node]: + name = Field.from_dsl(self.arguments[0]) if self.arguments else None + attrs = {} + for k, v in self.options.items(): + attrs[k] = Field.from_dsl(v) + content = Field.from_dsl(self.content[0]) if self.content else None + + self.env.temp_data[SCHEMA_KEY] = Schema(name, attrs, content) + + return [] + + @staticmethod + def directive_preset() -> Schema: + return Schema(name=Field(), attrs=Field(), content=Field()) + + @staticmethod + def role_preset() -> Schema: + return Schema(name=Field(), attrs={}, content=Field()) + + +class FreeDataDefineDirective(BaseDataDefineDirective, FreeStyleDirective): + optional_arguments = 1 + has_content = True + + @override + def current_schema(self) -> Schema: + schema = self.env.temp_data.get( + SCHEMA_KEY, SchemaDefineDirective.directive_preset() + ) + return cast(Schema, schema) + + @override + def current_template(self) -> Template: + tmpl = self.env.temp_data.get( + TEMPLATE_KEY, TemplateDefineDirective.directive_preset() + ) + return cast(Template, tmpl) + + +class DataRenderDirective(BaseContextDirective): + option_spec = { + 'on': phase_option_spec, + 'debug': directives.flag, + 'extra': directives.unchanged, + } + has_content = True + + @override + def current_context(self) -> PendingContext | ResolvedContext: + return {} + + @override + def current_template(self) -> Template: + extra_str = self.options.get('extra', '') + extra_list = extra_str.split() if extra_str else [] + + return Template( + '\n'.join(self.content), + phase=self.options.get('on', Phase.default()), + debug='debug' in self.options, + extra=extra_list, + ) + + +class FreeDataDefineRole(BaseDataDefineRole): + def __init__(self, orig_name: str) -> None: + self.orig_name = orig_name + + @override + def current_raw_data(self) -> RawData: + data = super().current_raw_data() + _, _, data.name = self.orig_name.partition('+') + return data + + @override + def current_schema(self) -> Schema: + schema = self.env.temp_data.get(SCHEMA_KEY, SchemaDefineDirective.role_preset()) + return cast(Schema, schema) + + @override + def current_template(self) -> Template: + tmpl = self.env.temp_data.get( + TEMPLATE_KEY, TemplateDefineDirective.role_preset() + ) + return cast(Template, tmpl) + + +class FreeDataDefineRoleDispatcher(CustomReSTDispatcher): + """Custom dispatcher for data define role. + + This enables :data:def+***:/def+***: roles on parsing reST document. + + .. seealso:: :class:`sphinx.ext.intersphinx.IntersphinxDispatcher`. + """ + + @override + def role( + self, + role_name: str, + language_module: ModuleType, + lineno: int, + reporter: Reporter, + ) -> tuple[RoleFunction, list[nodes.system_message]]: + if len(role_name) > 4 and role_name.startswith('data:define+'): + return FreeDataDefineRole(role_name), [] + else: + return super().role(role_name, language_module, lineno, reporter) + + def install(self, app: Sphinx, docname: str, source: list[str]) -> None: + """Enable role ispatcher. + + .. note:: The installed dispatcher will be uninstalled on disabling sphinx_domain + 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.add_role('data.define', FreeDataDefineRole) + + app.connect('source-read', FreeDataDefineRoleDispatcher().install) diff --git a/src/sphinxnotes/render/ext/derive.py b/src/sphinxnotes/render/ext/derive.py new file mode 100644 index 0000000..14359cc --- /dev/null +++ b/src/sphinxnotes/render/ext/derive.py @@ -0,0 +1,69 @@ +""" +sphinxnotes.render.ext.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 .. import Schema, Template, Phase, StrictDataDefineDirective + +from schema import Schema as DictSchema, SchemaError as DictSchemaError, Optional, Or +from sphinx.errors import ConfigError + +if TYPE_CHECKING: + from sphinx.application import Sphinx + from sphinx.config import Config + + +DATA_DEFINE_DIRECTIVE = DictSchema( + { + 'schema': { + Optional('name', default='str'): 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/src/sphinxnotes/render/ext/extractx.py b/src/sphinxnotes/render/ext/extractx.py new file mode 100644 index 0000000..e5266ca --- /dev/null +++ b/src/sphinxnotes/render/ext/extractx.py @@ -0,0 +1,81 @@ +""" +sphinxnotes.render.ext.extractx +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:copyright: Copyright 2025~2026 by the Shengyu Zhang. +:license: BSD, see LICENSE for details. + +Provides useful extra context. + +""" + +from __future__ import annotations +from typing import TYPE_CHECKING, override, Any + +from sphinx.util.docutils import SphinxDirective, SphinxRole + +from .. import meta, extra_context, GlobalExtraContext, ParsingPhaseExtraContext + +# FIXME: +from ..utils import find_current_section +from ..utils.ctxproxy import proxy + +if TYPE_CHECKING: + from sphinx.application import Sphinx + from sphinx.environment import BuildEnvironment + + +@extra_context('markup') +class MarkupExtraContext(ParsingPhaseExtraContext): + @override + def generate(self, directive: SphinxDirective | SphinxRole) -> Any: + isdir = isinstance(directive, SphinxDirective) + return { + 'type': 'directive' if isdir else 'role', + 'name': directive.name, + 'lineno': directive.lineno, + 'rawtext': directive.block_text if isdir else directive.rawtext, + } + + +@extra_context('doc') +class DocExtraContext(ParsingPhaseExtraContext): + @override + def generate(self, directive: SphinxDirective | SphinxRole) -> Any: + doctree = ( + directive.state.document + if isinstance(directive, SphinxDirective) + else directive.inliner.document + ) + return proxy(doctree) + + +@extra_context('section') +class SectionExtraContext(ParsingPhaseExtraContext): + @override + def generate(self, directive: SphinxDirective | SphinxRole) -> Any: + parent = ( + directive.state.parent + if isinstance(directive, SphinxDirective) + else directive.inliner.parent + ) + return proxy(find_current_section(parent)) + + +@extra_context('sphinx') +class SphinxAppExtraContext(GlobalExtraContext): + @override + def generate(self, env: BuildEnvironment) -> Any: + return proxy(env.app) + + +@extra_context('env') +class SphinxBuildEnvExtraContext(GlobalExtraContext): + @override + def generate(self, env: BuildEnvironment) -> Any: + return proxy(env) + + +def setup(app: Sphinx): + meta.pre_setup(app) + return meta.post_setup(app) diff --git a/src/sphinxnotes/render/ext/filters.py b/src/sphinxnotes/render/ext/filters.py new file mode 100644 index 0000000..2da2a8e --- /dev/null +++ b/src/sphinxnotes/render/ext/filters.py @@ -0,0 +1,53 @@ +""" +sphinxnotes.render.ext.filters +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:copyright: Copyright 2025~2026 by the Shengyu Zhang. +:license: BSD, see LICENSE for details. + +Provides useful Jinja2 template filter. + +""" + +from __future__ import annotations +from typing import TYPE_CHECKING, Any, Iterable +import json + +from .. import meta, filter + +if TYPE_CHECKING: + from sphinx.application import Sphinx + from sphinx.environment import BuildEnvironment + + +@filter('roles') +def roles(_: BuildEnvironment): + """ + Converting list of string to list of reStructuredText role. + + For example:: + + {{ ["foo", "bar"] | roles("doc") }} + + Produces ``[":doc:`foo`", ":doc:`bar`"]``. + """ + + def _filter(value: Iterable[str], role: str) -> Iterable[str]: + return map(lambda x: ':%s:`%s`' % (role, x), value) + + return _filter + + +@filter('jsonify') +def jsonify(_: BuildEnvironment): + """Converting value to JSON.""" + + def _filter(value: Any) -> Any: + return json.dumps(value, indent=' ') + + return _filter + + +def setup(app: Sphinx): + meta.pre_setup(app) + return meta.post_setup(app) diff --git a/src/sphinxnotes/render/extractx.py b/src/sphinxnotes/render/extractx.py index 5bf57c3..0cf278f 100644 --- a/src/sphinxnotes/render/extractx.py +++ b/src/sphinxnotes/render/extractx.py @@ -1,17 +1,16 @@ from __future__ import annotations -from typing import TYPE_CHECKING, ClassVar, override +from typing import TYPE_CHECKING, ClassVar from abc import ABC, abstractmethod from sphinx.util.docutils import SphinxDirective, SphinxRole from sphinx.transforms import SphinxTransform -from .render import HostWrapper, Phase +from .template import Phase from .ctxnodes import pending_node -from .utils import find_current_section, Report, Reporter +from .utils import Report, Reporter if TYPE_CHECKING: from typing import Any, Callable - from sphinx.application import Sphinx from sphinx.environment import BuildEnvironment # ============================ @@ -176,7 +175,7 @@ def __init__(self, node: pending_node) -> None: if nonavail := requested & total - avail: self.report.text( f'Extra contexts {nonavail} are not available ' - f'at pahse {node.template.phase}.' + f'at phase {node.template.phase}.' ) def on_anytime(self, env: BuildEnvironment) -> None: @@ -207,61 +206,3 @@ def _generate(self, cls: type[_ExtraContext], gen: Callable[..., Any]) -> None: except Exception: self.report.text(f'Failed to generate extra context "{name}":') self.report.traceback() - - -# ===================================== -# Builtin extra context implementations -# ===================================== - - -@extra_context('markup') -class MarkupExtraContext(ParsingPhaseExtraContext): - @override - def generate(self, directive: SphinxDirective | SphinxRole) -> Any: - isdir = isinstance(directive, SphinxDirective) - return { - 'type': 'directive' if isdir else 'role', - 'name': directive.name, - 'lineno': directive.lineno, - 'rawtext': directive.block_text if isdir else directive.rawtext, - } - - -@extra_context('doc') -class DocExtraContext(ParsingPhaseExtraContext): - @override - def generate(self, directive: SphinxDirective | SphinxRole) -> Any: - from .utils.ctxproxy import proxy - - return proxy(HostWrapper(directive).doctree) - - -@extra_context('section') -class SectionExtraContext(ParsingPhaseExtraContext): - @override - def generate(self, directive: SphinxDirective | SphinxRole) -> Any: - from .utils.ctxproxy import proxy - - parent = HostWrapper(directive).parent - return proxy(find_current_section(parent)) - - -@extra_context('sphinx') -class SphinxAppExtraContext(GlobalExtraContext): - @override - def generate(self, env: BuildEnvironment) -> Any: - from .utils.ctxproxy import proxy - - return proxy(env.app) - - -@extra_context('env') -class SphinxBuildEnvExtraContext(GlobalExtraContext): - @override - def generate(self, env: BuildEnvironment) -> Any: - from .utils.ctxproxy import proxy - - return proxy(env) - - -def setup(app: Sphinx): ... diff --git a/src/sphinxnotes/render/jinja.py b/src/sphinxnotes/render/jinja.py new file mode 100644 index 0000000..66e02d7 --- /dev/null +++ b/src/sphinxnotes/render/jinja.py @@ -0,0 +1,142 @@ +""" +sphinxnotes.jinja +~~~~~~~~~~~~~~~~~ + +Rendering Jinja2 template to markup text. + +:copyright: Copyright 2026 by the Shengyu Zhang. +:license: BSD, see LICENSE for details. +""" + +from __future__ import annotations +from dataclasses import dataclass +from pprint import pformat +from typing import TYPE_CHECKING, Callable, ClassVar, override + +from jinja2.sandbox import SandboxedEnvironment +from jinja2 import StrictUndefined, DebugUndefined + +from .data import ParsedData +from .utils import Report + +if TYPE_CHECKING: + from typing import Any + from sphinx.application import Sphinx + from sphinx.environment import BuildEnvironment + + +@dataclass +class TemplateRenderer: + text: str + + def render( + self, + data: ParsedData | dict[str, Any], + extra: dict[str, Any] | None = None, + debug: Report | None = None, + ) -> str: + if extra is None: + extra = {} + if debug: + debug.text('Starting Jinja template rendering...') + + debug.text('Data:') + debug.code(pformat(data), lang='python') + debug.text('Available extra context (just keys):') + debug.code(pformat(list(extra.keys())), lang='python') + + # Convert data to context dict. + if isinstance(data, ParsedData): + ctx = data.asdict() + elif isinstance(data, dict): + ctx = data.copy() + + # Inject load_extra() function for accessing extra context. + # TODO: move to extractx.py + def load_extra(name: str): + if name not in extra: + raise ValueError( + f'Extra context "{name}" is not available. ' + f'Available: {list(extra.keys())}' + ) + return extra[name] + + ctx['load_extra'] = load_extra + + text = self._render(ctx, debug=debug is not None) + + return text + + def _render(self, ctx: dict[str, Any], debug: bool = False) -> str: + extensions = [ + 'jinja2.ext.loopcontrols', # enable {% break %}, {% continue %} + 'jinja2.ext.do', # enable {% do ... %} + ] + if debug: + extensions.append('jinja2.ext.debug') + + env = _JinjaEnv( + undefined=DebugUndefined if debug else StrictUndefined, + extensions=extensions, + ) + # TODO: cache jinja env + + return env.from_string(self.text).render(ctx) + + def _report_self(self, reporter: Report) -> None: + reporter.text('Template:') + reporter.code(self.text, lang='jinja') + + +class _JinjaEnv(SandboxedEnvironment): + _env: ClassVar[BuildEnvironment] + _filter_factories: ClassVar[dict[str, Callable[[BuildEnvironment], Callable]]] = {} + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + for name, factory in self._filter_factories.items(): + self.filters[name] = factory(self._env) + + @classmethod + def on_builder_inited(cls, app: Sphinx): + cls._env = app.env + + @classmethod + def add_filter(cls, name: str, factory: Callable[[BuildEnvironment], Callable]): + cls._filter_factories[name] = factory + + @override + def is_safe_attribute(self, obj, attr, value=None): + """ + The sandboxed environment will call this method to check if the + attribute of an object is safe to access. Per default all attributes + starting with an underscore are considered private as well as the + special attributes of internal python objects as returned by the + is_internal_attribute() function. + + .. seealso:: :class:`..utils.ctxproxy.Proxy` + """ + return super().is_safe_attribute(obj, attr, value) + + +def filter(name: str): + """Decorator for adding a filter to the Jinja environment. + + Usage:: + + @filter('my_filter') + def my_filter(env: BuildEnvironment): + def _filter(value): + return value.upper() + return _filter + """ + + def decorator(ff): + _JinjaEnv.add_filter(name, ff) + return ff + + return decorator + + +def setup(app: Sphinx): + app.connect('builder-inited', _JinjaEnv.on_builder_inited) diff --git a/src/sphinxnotes/render/markup.py b/src/sphinxnotes/render/markup.py index 5fed845..d648bb2 100644 --- a/src/sphinxnotes/render/markup.py +++ b/src/sphinxnotes/render/markup.py @@ -20,7 +20,7 @@ from sphinx.transforms import SphinxTransform from sphinx.environment.collectors.asset import ImageCollector -from .render import Host +from .template import Host if TYPE_CHECKING: from docutils.nodes import Node, system_message diff --git a/src/sphinxnotes/render/meta.py b/src/sphinxnotes/render/meta.py index 8af588d..caebc12 100644 --- a/src/sphinxnotes/render/meta.py +++ b/src/sphinxnotes/render/meta.py @@ -10,7 +10,7 @@ __project__ = 'sphinxnotes-render' __author__ = 'Shengyu Zhang' -__desc__ = 'A framework to define, constrain, and render data in Sphinx documentation' +__desc__ = 'Define, constrain, and render data in Sphinx documentation' try: __version__ = metadata.version('sphinxnotes-render') diff --git a/src/sphinxnotes/render/pipeline.py b/src/sphinxnotes/render/pipeline.py index db5d131..3a53114 100644 --- a/src/sphinxnotes/render/pipeline.py +++ b/src/sphinxnotes/render/pipeline.py @@ -10,7 +10,7 @@ from sphinx.transforms import SphinxTransform from sphinx.transforms.post_transforms import SphinxPostTransform, ReferencesResolver -from .render import HostWrapper, Phase, Template, Host, ParseHost, ResolveHost +from .template import HostWrapper, Phase, Template, Host from .ctx import PendingContext, ResolvedContext from .ctxnodes import pending_node from .extractx import ExtraContextGenerator @@ -184,8 +184,7 @@ def current_template(self) -> Template: @override def process_pending_node(self, n: pending_node) -> bool: - host = cast(ParseHost, self) - + host = cast(SphinxDirective | SphinxRole, self) # Set source and line. host.set_source_info(n) # Generate and save parsing phase extra context for later use. @@ -195,13 +194,14 @@ def process_pending_node(self, n: pending_node) -> bool: class BaseContextDirective(BaseContextSource, SphinxDirective): - """This class generates :py:meth:`sphinxnotes.render.pending_node` in - ``SphinxDirective.run`` method and makes sure it is rendered correctly. + """This class generates :py:class:`sphinxnotes.render.pending_node` in + ``SphinxDirective.run()`` method and makes sure it is rendered correctly. User should implement ``current_context`` and ``current_template`` methods to provide the constructor parameters of ``pending_node``. """ + @final @override def run(self) -> list[nodes.Node]: self.queue_context(self.current_context(), self.current_template()) @@ -217,8 +217,8 @@ def run(self) -> list[nodes.Node]: class BaseContextRole(BaseContextSource, SphinxRole): - """This class generates :py:meth:`sphinxnotes.render.pending_node` in - ``SphinxRole.run`` method and makes sure it is rendered correctly. + """This class generates :py:class:`sphinxnotes.render.pending_node` in + ``SphinxRole.run()`` method and makes sure it is rendered correctly. User should implement ``current_context`` and ``current_template`` methods to provide the constructor parameters of ``pending_node``. @@ -229,6 +229,7 @@ def process_pending_node(self, n: pending_node) -> bool: n.inline = True return super().process_pending_node(n) + @final @override def run(self) -> tuple[list[nodes.Node], list[nodes.system_message]]: pending = self.queue_context(self.current_context(), self.current_template()) @@ -246,13 +247,13 @@ def run(self) -> tuple[list[nodes.Node], list[nodes.system_message]]: return ns, msgs -class ParsedHookTransform(SphinxTransform, Pipeline): +class _ParsedHookTransform(SphinxTransform, Pipeline): # Before almost all others. default_priority = 100 @override def process_pending_node(self, n: pending_node) -> bool: - ExtraContextGenerator(n).on_parsed(cast(ResolveHost, self)) + ExtraContextGenerator(n).on_parsed(self) return n.template.phase == Phase.Parsed @override @@ -264,13 +265,13 @@ def apply(self, **kwargs): ... -class ResolvingHookTransform(SphinxPostTransform, Pipeline): +class _ResolvingHookTransform(SphinxPostTransform, Pipeline): # After resolving pending_xref default_priority = (ReferencesResolver.default_priority or 10) + 5 @override def process_pending_node(self, n: pending_node) -> bool: - ExtraContextGenerator(n).on_resolving(cast(ResolveHost, self)) + ExtraContextGenerator(n).on_resolving(self) return n.template.phase == Phase.Resolving @override @@ -285,7 +286,7 @@ def apply(self, **kwargs): def setup(app: Sphinx) -> None: # Hook for Phase.Parsed. - app.add_transform(ParsedHookTransform) + app.add_transform(_ParsedHookTransform) # Hook for Phase.Resolving. - app.add_post_transform(ResolvingHookTransform) + app.add_post_transform(_ResolvingHookTransform) diff --git a/src/sphinxnotes/render/render.py b/src/sphinxnotes/render/render.py deleted file mode 100644 index 57d696c..0000000 --- a/src/sphinxnotes/render/render.py +++ /dev/null @@ -1,75 +0,0 @@ -from __future__ import annotations -from dataclasses import dataclass, field -from enum import Enum - -from docutils import nodes -from sphinx.transforms import SphinxTransform -from sphinx.util.docutils import SphinxDirective, SphinxRole - - -class Phase(Enum): - """The phase of rendering template.""" - - #: Render template on document parsing - #: on (:py:class:`~sphinx.util.docutils.SphinxDirective`\ ``.run()`` or - #: :py:class:`~sphinx.util.docutils.SphinxRole`\ ``.run()``). - Parsing = 'parsing' - #: Render template immediately after document has parsed - #: (on Sphinx event :external:event:`doctree-read`). - Parsed = 'parsed' - #: Render template immediately after all documents have resolving - #: (before Sphinx event :external:event:`doctree-resolved`). - Resolving = 'resolving' - - @classmethod - def default(cls) -> Phase: - return cls.Parsing - - def __ge__(self, other: Phase) -> bool: - _ORDER = {Phase.Parsing: 1, Phase.Parsed: 2, Phase.Resolving: 3} - return _ORDER[self] >= _ORDER[other] - - -@dataclass -class Template: - #: Jinja template for rendering the context. - text: str - #: The render phase. - phase: Phase = Phase.default() - #: Enable debug output (shown as :py:class:`docutils.nodes.system_message` in document.) - debug: bool = False - #: Names of extra context to be generated and available in the template. - extra: list[str] = field(default_factory=list) - - -#: Possible render host of :meth:`pending_node.render`. -type Host = ParseHost | ResolveHost -#: Host of source parse phase (Phase.Parsing, Phase.Parsed). -type ParseHost = SphinxDirective | SphinxRole -#: Host of source parse phase (Phase.Parsing, Phase.Parsed). -type ResolveHost = SphinxTransform - - -@dataclass -class HostWrapper: - v: Host - - @property - def doctree(self) -> nodes.document: - if isinstance(self.v, SphinxDirective): - return self.v.state.document - elif isinstance(self.v, SphinxRole): - return self.v.inliner.document - elif isinstance(self.v, SphinxTransform): - return self.v.document - else: - raise NotImplementedError - - @property - def parent(self) -> nodes.Element | None: - if isinstance(self.v, SphinxDirective): - return self.v.state.parent - elif isinstance(self.v, SphinxRole): - return self.v.inliner.parent - else: - return None diff --git a/src/sphinxnotes/render/sources.py b/src/sphinxnotes/render/sources.py index 9b82241..d941b10 100644 --- a/src/sphinxnotes/render/sources.py +++ b/src/sphinxnotes/render/sources.py @@ -9,7 +9,7 @@ """ from __future__ import annotations -from typing import override +from typing import final, override from abc import abstractmethod from dataclasses import dataclass @@ -17,7 +17,7 @@ from .data import Field, RawData, Schema from .ctx import PendingContext, ResolvedContext -from .render import Template +from .template import Template from .pipeline import BaseContextSource, BaseContextDirective, BaseContextRole @@ -54,12 +54,11 @@ def current_raw_data(self) -> RawData: ... def current_schema(self) -> Schema: """Return the schema for constraining the generated :py:class`~sphinxnotes.render.RawData`. see :doc:`tmpl` for more details. - - .. note:: User **must not** override ``current_context`` method of this class. """ """Methods to be overridden.""" + @final @override def current_context(self) -> PendingContext | ResolvedContext: return UnparsedData(self.current_raw_data(), self.current_schema()) @@ -70,6 +69,19 @@ class BaseDataDefineDirective(BaseRawDataSource, BaseContextDirective): @override def current_raw_data(self) -> RawData: + """ + Return the :py:class:`~sphinxnotes.render.RawData` generating from + from directive's arguments, options, and content, and then it will be + parsed by :py:class:`~sphinxnotes.render.Schema` returned from + ``current_schema`` method. + + See :ref:`context` for more details. + + .. note:: + + In most cases, the default implementation works well and you don't + need to override it. + """ return RawData( ' '.join(self.arguments) if self.arguments else None, self.options.copy(), @@ -80,6 +92,19 @@ def current_raw_data(self) -> RawData: class BaseDataDefineRole(BaseRawDataSource, BaseContextRole): @override def current_raw_data(self) -> RawData: + """ + Return the :py:class:`~sphinxnotes.render.RawData` generating from + from roles's text, and then it will be parsed by + :py:class:`~sphinxnotes.render.Schema` returned from ``current_schema`` + method. + + See :ref:`context` for more details. + + .. note:: + + In most cases, the default implementation works well and you don't + need to override it. + """ return RawData(self.name, self.options.copy(), self.text) diff --git a/src/sphinxnotes/render/template.py b/src/sphinxnotes/render/template.py index 0b34169..013be6b 100644 --- a/src/sphinxnotes/render/template.py +++ b/src/sphinxnotes/render/template.py @@ -1,149 +1,66 @@ -""" -sphinxnotes.template -~~~~~~~~~~~~~~~~~~~~ - -Rendering Jinja2 template to markup text. - -:copyright: Copyright 2026 by the Shengyu Zhang. -:license: BSD, see LICENSE for details. -""" - from __future__ import annotations -from dataclasses import dataclass -from pprint import pformat -from typing import TYPE_CHECKING, override - -from jinja2.sandbox import SandboxedEnvironment -from jinja2 import StrictUndefined, DebugUndefined - -from .data import ParsedData -from .utils import Report - -if TYPE_CHECKING: - from typing import Any, Iterable - from sphinx.application import Sphinx - from sphinx.environment import BuildEnvironment - from sphinx.builders import Builder - - -@dataclass -class TemplateRenderer: - text: str - - def render( - self, - data: ParsedData | dict[str, Any], - extra: dict[str, Any] = {}, - debug: Report | None = None, - ) -> str: - if debug: - debug.text('Starting Jinja template rendering...') - - debug.text('Data:') - debug.code(pformat(data), lang='python') - debug.text('Available extra context (just keys):') - debug.code(pformat(list(extra.keys())), lang='python') - - # Convert data to context dict. - if isinstance(data, ParsedData): - ctx = data.asdict() - elif isinstance(data, dict): - ctx = data.copy() - - # Inject load_extra() function for accessing extra context. - def load_extra(name: str): - if name not in extra: - raise ValueError( - f'Extra context "{name}" is not available. ' - f'Available: {list(extra.keys())}' - ) - return extra[name] - - ctx['load_extra'] = load_extra - - text = self._render(ctx, debug=debug is not None) +from dataclasses import dataclass, field +from enum import Enum - return text +from docutils import nodes +from sphinx.transforms import SphinxTransform +from sphinx.util.docutils import SphinxDirective, SphinxRole - def _render(self, ctx: dict[str, Any], debug: bool = False) -> str: - extensions = [ - 'jinja2.ext.loopcontrols', # enable {% break %}, {% continue %} - 'jinja2.ext.do', # enable {% do ... %} - ] - if debug: - extensions.append('jinja2.ext.debug') - env = _JinjaEnv( - undefined=DebugUndefined if debug else StrictUndefined, - extensions=extensions, - ) - # TODO: cache jinja env +class Phase(Enum): + """The phase of rendering template.""" - return env.from_string(self.text).render(ctx) - - def _report_self(self, reporter: Report) -> None: - reporter.text('Template:') - reporter.code(self.text, lang='jinja') - - -class _JinjaEnv(SandboxedEnvironment): - _builder: Builder - # List of user defined filter factories. - _filter_factories = {} - - @classmethod - def _on_builder_inited(cls, app: Sphinx): - cls._builder = app.builder - - @classmethod - def _on_build_finished(cls, app: Sphinx, exception): ... + #: Render template on document parsing + #: on (:py:class:`~sphinx.util.docutils.SphinxDirective`\ ``.run()`` or + #: :py:class:`~sphinx.util.docutils.SphinxRole`\ ``.run()``). + Parsing = 'parsing' + #: Render template immediately after document has parsed + #: (on Sphinx event :external:event:`doctree-read`). + Parsed = 'parsed' + #: Render template immediately after all documents have resolving + #: (before Sphinx event :external:event:`doctree-resolved`). + Resolving = 'resolving' @classmethod - def add_filter(cls, name: str, ff): - cls._filter_factories[name] = ff - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - for name, factory in self._filter_factories.items(): - self.filters[name] = factory(self._builder.env) + def default(cls) -> Phase: + return cls.Parsing - @override - def is_safe_attribute(self, obj, attr, value=None): - """ - The sandboxed environment will call this method to check if the - attribute of an object is safe to access. Per default all attributes - starting with an underscore are considered private as well as the - special attributes of internal python objects as returned by the - is_internal_attribute() function. + def __ge__(self, other: Phase) -> bool: + _ORDER = {Phase.Parsing: 1, Phase.Parsed: 2, Phase.Resolving: 3} + return _ORDER[self] >= _ORDER[other] - .. seealso:: :class:`..utils.ctxproxy.Proxy` - """ - return super().is_safe_attribute(obj, attr, value) +@dataclass +class Template: + #: Jinja template for rendering the context. + text: str + #: The render phase. + phase: Phase = Phase.default() + #: Enable debug output (shown as :py:class:`docutils.nodes.system_message` in document.) + debug: bool = False + #: Names of extra context to be generated and available in the template. + extra: list[str] = field(default_factory=list) -def _roles_filter(env: BuildEnvironment): - """ - Fetch artwork picture by ID and install theme to Sphinx's source directory, - return the relative URI of current doc root. - """ - - def _filter(value: Iterable[str], role: str) -> Iterable[str]: - """ - A heplfer filter for converting list of string to list of role. - - For example:: - - {{ ["foo", "bar"] | roles("doc") }} - - Produces ``[":doc:`foo`", ":doc:`bar`"]``. - """ - return map(lambda x: ':%s:`%s`' % (role, x), value) - - return _filter +#: Possible render host of :meth:`pending_node.render`. +type Host = ParseHost | ResolveHost +#: Host of source parse phase (Phase.Parsing, Phase.Parsed). +type ParseHost = SphinxDirective | SphinxRole +#: Host of source parse phase (Phase.Parsing, Phase.Parsed). +type ResolveHost = SphinxTransform -def setup(app: Sphinx): - app.connect('builder-inited', _JinjaEnv._on_builder_inited) - app.connect('build-finished', _JinjaEnv._on_build_finished) - _JinjaEnv.add_filter('roles', _roles_filter) +@dataclass +class HostWrapper: + v: Host + + @property + def doctree(self) -> nodes.document: + if isinstance(self.v, SphinxDirective): + return self.v.state.document + elif isinstance(self.v, SphinxRole): + return self.v.inliner.document + elif isinstance(self.v, SphinxTransform): + return self.v.document + else: + raise NotImplementedError diff --git a/tests/roots/test-base-context-directive-example/conf.py b/tests/roots/test-base-context-directive-example/conf.py new file mode 100644 index 0000000..2bed617 --- /dev/null +++ b/tests/roots/test-base-context-directive-example/conf.py @@ -0,0 +1,23 @@ +from sphinx.application import Sphinx +from sphinxnotes.render import ParsedData, BaseContextDirective, Template, Phase + + +class MimiDirective(BaseContextDirective): + def current_context(self): + return ParsedData( + name='mimi', + attrs={'color': 'black and brown'}, + content='I like fish!', + ) + + def current_template(self): + return Template( + 'Hi human! I am a cat named {{ name }}, I have {{ color }} fur.\n\n' + '{{ content }}.', + phase=Phase.Parsing, + ) + + +def setup(app: Sphinx): + app.setup_extension('sphinxnotes.render') + app.add_directive('mimi', MimiDirective) diff --git a/tests/roots/test-base-context-directive-example/index.rst b/tests/roots/test-base-context-directive-example/index.rst new file mode 100644 index 0000000..901f0fb --- /dev/null +++ b/tests/roots/test-base-context-directive-example/index.rst @@ -0,0 +1,4 @@ +BaseContextDirective Test +========================= + +.. mimi:: diff --git a/tests/roots/test-base-data-define-directive-example/conf.py b/tests/roots/test-base-data-define-directive-example/conf.py new file mode 100644 index 0000000..3d05ffc --- /dev/null +++ b/tests/roots/test-base-data-define-directive-example/conf.py @@ -0,0 +1,42 @@ +from datetime import datetime + +from docutils.parsers.rst import directives +from sphinx.application import Sphinx +from sphinxnotes.render import ( + BaseDataDefineDirective, + Schema, + Field, + Template, +) + + +class CatDirective(BaseDataDefineDirective): + required_arguments = 1 + option_spec = { + 'color': directives.unchanged, + 'birth': directives.unchanged, + } + has_content = True + + def current_schema(self): + return Schema( + name=Field.from_dsl('str'), + attrs={ + 'color': Field.from_dsl('list of str'), + 'birth': Field.from_dsl('int'), + }, + content=Field.from_dsl('str'), + ) + + def current_template(self): + year = datetime.now().year + return Template( + 'Hi human! I am a cat named {{ name }}, I have {{ "and".join(color) }} fur.\n' + f'I am {{{{ {year} - birth }}}} years old.\n\n' + '{{ content }}.' + ) + + +def setup(app: Sphinx): + app.setup_extension('sphinxnotes.render') + app.add_directive('cat2', CatDirective) diff --git a/tests/roots/test-base-data-define-directive-example/index.rst b/tests/roots/test-base-data-define-directive-example/index.rst new file mode 100644 index 0000000..00f4eb9 --- /dev/null +++ b/tests/roots/test-base-data-define-directive-example/index.rst @@ -0,0 +1,14 @@ +Test BaseDataDefineDirective +============================= + +.. cat2:: mimi + :color: black and brown + :birth: 2025 + + I like fish! + +.. cat2:: lucy + :color: white + :birth: 2021 + + I like tuna! diff --git a/tests/roots/test-ctxdir-usage/conf.py b/tests/roots/test-ctxdir-usage/conf.py deleted file mode 100644 index f0ee2d8..0000000 --- a/tests/roots/test-ctxdir-usage/conf.py +++ /dev/null @@ -1,15 +0,0 @@ -from sphinx.application import Sphinx -from sphinxnotes.render import ParsedData, BaseContextDirective, Template, Phase - - -class MyDirective(BaseContextDirective): - def current_context(self): - return ParsedData('Shengyu Zhang', {}, None) - - def current_template(self): - return Template('My name is {{ name }}', phase=Phase.Parsing) - - -def setup(app: Sphinx): - app.setup_extension('sphinxnotes.render') - app.add_directive('me', MyDirective) diff --git a/tests/roots/test-ctxdir-usage/index.rst b/tests/roots/test-ctxdir-usage/index.rst deleted file mode 100644 index 571cda4..0000000 --- a/tests/roots/test-ctxdir-usage/index.rst +++ /dev/null @@ -1,4 +0,0 @@ -Smoke Test -========== - -.. me:: diff --git a/tests/roots/test-data-define/conf.py b/tests/roots/test-data-define/conf.py new file mode 100644 index 0000000..294e8a2 --- /dev/null +++ b/tests/roots/test-data-define/conf.py @@ -0,0 +1 @@ +extensions = ['sphinxnotes.render.ext'] diff --git a/tests/roots/test-data-define/index.rst b/tests/roots/test-data-define/index.rst new file mode 100644 index 0000000..be9c21b --- /dev/null +++ b/tests/roots/test-data-define/index.rst @@ -0,0 +1,18 @@ +Smoke Test +========== + +.. data.template:: + + Rendered{{ name }} + + {% for k, v in attrs.items() %} + Rendered{{ k }}: Rendered{{ v }} + {% endfor %} + + Rendered{{ content }} + +.. data.define:: Name + :Attr1: Value1 + :Attr2: Value2 + + Content diff --git a/tests/roots/test-derive/conf.py b/tests/roots/test-derive/conf.py new file mode 100644 index 0000000..6b05c1c --- /dev/null +++ b/tests/roots/test-derive/conf.py @@ -0,0 +1,16 @@ +keep_warnings = True + +extensions = ['sphinxnotes.render.ext'] + +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/roots/test-extra-context/cat.json b/tests/roots/test-extra-context/cat.json new file mode 100644 index 0000000..88b4c1e --- /dev/null +++ b/tests/roots/test-extra-context/cat.json @@ -0,0 +1,7 @@ +{ + "name": "mimi", + "attrs": { + "color": "black and brown" + }, + "content": "I like fish!" +} diff --git a/tests/roots/test-extra-context/conf.py b/tests/roots/test-extra-context/conf.py index 82b6e94..10bef32 100644 --- a/tests/roots/test-extra-context/conf.py +++ b/tests/roots/test-extra-context/conf.py @@ -1,41 +1,25 @@ -from sphinx.application import Sphinx +# [literalinclude start] +from os import path +import json + +from sphinx.environment import BuildEnvironment from sphinxnotes.render import ( extra_context, - ParsingPhaseExtraContext, GlobalExtraContext, - BaseContextDirective, - Template, ) -@extra_context('custom_parsing') -class CustomParsingExtraContext(ParsingPhaseExtraContext): - def generate(self, directive): - return {'custom_value': 'parsing_test'} - +@extra_context('cat') +class CatExtraContext(GlobalExtraContext): + def generate(self, env: BuildEnvironment): + with open(path.join(path.dirname(__file__), 'cat.json')) as f: + return json.loads(f.read()) -@extra_context('custom_global') -class CustomGlobalExtraContext(GlobalExtraContext): - def generate(self, env): - return {'custom_value': 'global_test'} +# [literalinclude end] -class CustomExtraContextDirective(BaseContextDirective): - def current_context(self): - return {} - def current_template(self): - return Template( - """ -{% set _parsing = load_extra('custom_parsing') %} -{% set _global = load_extra('custom_global') %} -Parsing: {{ _parsing.custom_value }} -Global: {{ _global.custom_value }} -""", - extra=['custom_parsing', 'custom_global'], - ) +extensions = ['sphinxnotes.render.ext'] -def setup(app: Sphinx): - app.setup_extension('sphinxnotes.render') - app.add_directive('custom-extra', CustomExtraContextDirective) +def setup(app): ... diff --git a/tests/roots/test-extra-context/index.rst b/tests/roots/test-extra-context/index.rst index e4a4241..dcd20d2 100644 --- a/tests/roots/test-extra-context/index.rst +++ b/tests/roots/test-extra-context/index.rst @@ -1,4 +1,7 @@ Extra Context Test ================== -.. custom-extra:: +.. data.render:: + :extra: cat + + {{ load_extra('cat') }} diff --git a/tests/roots/test-filter-example/conf.py b/tests/roots/test-filter-example/conf.py new file mode 100644 index 0000000..acd0b63 --- /dev/null +++ b/tests/roots/test-filter-example/conf.py @@ -0,0 +1,22 @@ +from sphinxnotes.render import filter +from sphinx.environment import BuildEnvironment + + +# [literalinclude start] +@filter('catify') +def catify(_: BuildEnvironment): + """Speak in a cat-like tone""" + + def _filter(value: str) -> str: + return value + ', meow~' + + return _filter + + +# [literalinclude end] + + +extensions = ['sphinxnotes.render.ext'] + + +def setup(app): ... diff --git a/tests/roots/test-filter-example/index.rst b/tests/roots/test-filter-example/index.rst new file mode 100644 index 0000000..fbf509b --- /dev/null +++ b/tests/roots/test-filter-example/index.rst @@ -0,0 +1,6 @@ +test filter +============ + +.. data.render:: + + {{ "I love you" | catify }} diff --git a/tests/roots/test-strict-data-define-directive-example/conf.py b/tests/roots/test-strict-data-define-directive-example/conf.py new file mode 100644 index 0000000..85c8939 --- /dev/null +++ b/tests/roots/test-strict-data-define-directive-example/conf.py @@ -0,0 +1,31 @@ +from datetime import datetime + +from sphinx.application import Sphinx +from sphinxnotes.render import ( + StrictDataDefineDirective, + Schema, + Field, + Template, +) + +schema = Schema( + name=Field.from_dsl('str'), + attrs={ + 'color': Field.from_dsl('list of str'), + 'birth': Field.from_dsl('int'), + }, + content=Field.from_dsl('str'), +) + +template = Template( + 'Hi human! I am a cat named {{ name }}, I have {{ "and".join(color) }} fur.\n' + f'I am {{{{ {datetime.now().year} - birth }}}} years old.\n\n' + '{{ content }}.' +) + +CatDirective = StrictDataDefineDirective.derive('cat', schema, template) + + +def setup(app: Sphinx): + app.setup_extension('sphinxnotes.render') + app.add_directive('cat3', CatDirective) diff --git a/tests/roots/test-strict-data-define-directive-example/index.rst b/tests/roots/test-strict-data-define-directive-example/index.rst new file mode 100644 index 0000000..1d8c893 --- /dev/null +++ b/tests/roots/test-strict-data-define-directive-example/index.rst @@ -0,0 +1,14 @@ +Test StrictDataDefineDirective +=============================== + +.. cat3:: mimi + :color: black and brown + :birth: 2025 + + I like fish! + +.. cat3:: lucy + :color: white + :birth: 2021 + + I like tuna! diff --git a/tests/test_e2e.py b/tests/test_e2e.py new file mode 100644 index 0000000..fa6f797 --- /dev/null +++ b/tests/test_e2e.py @@ -0,0 +1,98 @@ +"""E2E tests for sphinxnotes.render extension.""" + +import pytest + + +@pytest.mark.sphinx('html', testroot='filter-example') +def test_custom_filter(app, status, warning): + app.build() + + html = (app.outdir / 'index.html').read_text(encoding='utf-8') + + assert 'I love you, meow~' in html + + +@pytest.mark.sphinx('html', testroot='strict-data-define-directive-example') +def test_strict_data_define_directive(app, status, warning): + app.build() + + html = (app.outdir / 'index.html').read_text(encoding='utf-8') + + assert 'Hi human! I am a cat named mimi, I have black and brown fur.' in html + assert 'I like fish!' in html + assert 'I am 1 years old.' in html + assert 'Hi human! I am a cat named lucy, I have white fur.' in html + assert 'I am 5 years old.' in html + assert 'I like tuna!' in html + + +@pytest.mark.sphinx('html', testroot='base-data-define-directive-example') +def test_base_data_define_directive(app, status, warning): + app.build() + + html = (app.outdir / 'index.html').read_text(encoding='utf-8') + + assert 'Hi human! I am a cat named mimi, I have black and brown fur.' in html + assert 'I like fish!' in html + assert 'I am 1 years old.' in html + assert 'Hi human! I am a cat named lucy, I have white fur.' in html + assert 'I am 5 years old.' in html + assert 'I like tuna!' in html + + +@pytest.mark.sphinx('html', testroot='base-context-directive-example') +def test_base_context_directive(app, status, warning): + app.build() + + html = (app.outdir / 'index.html').read_text(encoding='utf-8') + + assert 'Hi human! I am a cat named mimi, I have black and brown fur.' in html + assert 'I like fish!' in html + + +@pytest.mark.sphinx('html', testroot='extra-context') +def test_extra_context_custom_loader(app, status, warning): + app.build() + + html = (app.outdir / 'index.html').read_text(encoding='utf-8') + + assert 'mimi' in html + + +# =========================== +# Test sphinxnotes.render.ext +# =========================== + +PHASES = ['parsing', 'parsed', 'resolving'] + + +@pytest.mark.sphinx('html', testroot='data-define') +@pytest.mark.parametrize('phase', PHASES) +def test_data_define_directives(app, status, warning, phase): + """Test that data.template and data.define directives work correctly.""" + index_path = app.srcdir / 'index.rst' + content = index_path.read_text(encoding='utf-8') + modified_content = content.replace(':on: {phase}', f':on: {phase}', 1) + index_path.write_text(modified_content, encoding='utf-8') + + app.build() + + html = (app.outdir / 'index.html').read_text(encoding='utf-8') + + assert 'RenderedName' in html + assert 'RenderedAttr1' in html + assert 'RenderedAttr2' in html + assert 'RenderedValue1' in html + assert 'RenderedValue2' in html + assert 'RenderedContent' in html + + +@pytest.mark.sphinx('html', testroot='derive') +def test_derived_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”.' diff --git a/tests/test_smoke.py b/tests/test_smoke.py deleted file mode 100644 index 9fa23b2..0000000 --- a/tests/test_smoke.py +++ /dev/null @@ -1,38 +0,0 @@ -"""Smoke tests for sphinxnotes.render extension.""" - -import pytest - - -@pytest.mark.sphinx('html', testroot='ctxdir-usage') -def test_base_(app, status, warning): - app.build() - - html = (app.outdir / 'index.html').read_text(encoding='utf-8') - - assert 'My name is Shengyu Zhang' in html - - -# -- literalinclude:start:end-to-end-card -- -@pytest.mark.sphinx('html', testroot='strictdir-card') -def test_strict_data_define_directive_card(app, status, warning): - app.build() - - html = (app.outdir / 'index.html').read_text(encoding='utf-8') - - assert 'Template Guide' in html - assert 'Featured entry' in html - assert 'jinja, docs' in html - assert 'This page explains the template context.' in html - - -# -- literalinclude:end:end-to-end-card -- - - -@pytest.mark.sphinx('html', testroot='extra-context') -def test_extra_context_custom_loader(app, status, warning): - app.build() - - html = (app.outdir / 'index.html').read_text(encoding='utf-8') - - assert 'Parsing: parsing_test' in html - assert 'Global: global_test' in html