Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Goodmap is a map engine designed to serve all the people :)

installation
quickstart
frontend-integration
plugins
api
development

Expand Down
171 changes: 171 additions & 0 deletions docs/plugins.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
Plugins
=======

Goodmap supports platzky plugins — standalone Python packages that extend
functionality via shortcodes, custom location field renderers, and module
federation frontend components.

Overview
--------

Plugins are discovered automatically through Python entry points
(``platzky.plugins`` group). Each plugin can:

* Register shortcodes for blog/content rendering
* Expose a React component via Module Federation for rendering inside map
popups
* Provide static assets served by the Flask backend

When a plugin-contributed field appears in a location's ``visible_data`` and
the plugin is configured, the API wraps the field value with
``{"scope": "<shortcode_name>", ...}``. The frontend detects the ``scope``
key and renders the appropriate plugin component.

Configuration
-------------

Add the plugin entry to the ``plugins`` list in your data source (e.g.
``data.json``):

.. code-block:: json

{
"plugins": [
{
"name": "promocode",
"config": {
"text": "Reveal your discount",
"color": "#e63946"
}
}
]
}

Each plugin has its own configuration schema — refer to the plugin's
documentation for available fields.

After adding or removing a plugin, restart the Flask server.

If a plugin is removed from the configuration while a location still has
fields referencing it, those fields are silently dropped from the API
response. A debug message is logged:

.. code-block:: text

DEBUG:goodmap.formatter:Dropping field 'promocode': unconfigured plugin data ...

To see these messages, enable debug logging:

.. code-block:: bash

export FLASK_DEBUG=1



Architecture
------------

Field Resolution Flow
~~~~~~~~~~~~~~~~~~~~~

1. Application starts in ``goodmap.py:create_app_from_config()``:
* Platzky loads plugins configured in the database via ``plugify()``
* Each ``ContentTransformerPluginBase`` subclass registers its shortcodes
in ``app.shortcodes``
* ``field_renderers`` is auto-populated from ``app.shortcodes``::

for sc_name in app.shortcodes:
field_renderers.setdefault(sc_name, sc_name)

2. When a location's ``visible_data`` includes a field matching a shortcode
name, ``formatter.py:_apply_field_plugin()`` wraps it:

* If the field name is in ``field_renderers``:
``{"scope": "<shortcode>", ...original fields...}``
* If the value is a ``dict`` with a ``code`` key, it is base64-encoded
for safe transport
* If the field name is NOT in ``field_renderers``, the value is a ``dict``
with a ``code`` key but no ``type``/``scope`` — it is treated as
unconfigured plugin data and dropped (with a debug log)

3. The frontend receives the wrapped value and renders:

* ``mapCustomTypeToReactComponent`` checks for ``customValue.scope``
* Delegates to ``<PluginSlot scope={scope} props={props} />``
* ``PluginSlot`` looks up the registered component in the plugin registry
and renders it with the remaining props

Module Federation
~~~~~~~~~~~~~~~~~

Plugin frontend components are loaded as Webpack 5 Module Federation remotes:

1. The backend discovers plugin entry points and registers Flask blueprints
to serve each plugin's ``static/`` directory
2. A ``PLUGIN_MANIFEST`` is embedded in ``map.html`` as
``window.PLUGIN_MANIFEST``
3. The frontend's ``pluginLoader.js`` reads the manifest, loads each remote,
initialises the shared scope, and registers the component with the
``pluginRegistry``
4. ``PluginSlot`` subscribes to registry changes and re-renders once the
component is available

CORS headers (``Access-Control-Allow-Origin: *``) are set on plugin static
blueprints to allow the frontend dev server to fetch the remote entry.

Field Lifecycle
---------------

+----------------------+-----------------------------------------------+--------------------------------------------+
| Scenario | API Response | Frontend Behaviour |
+======================+===============================================+============================================+
| Plugin configured | ``{"scope": "promocode", "code": "BASE64", | ``PluginSlot`` renders MF component |
| | "text": "...", "color": "#..."}`` | |
+----------------------+-----------------------------------------------+--------------------------------------------+
| Plugin NOT | Field omitted from response, debug log | Field not displayed at all |
| configured | written | |
+----------------------+-----------------------------------------------+--------------------------------------------+
| Standard custom type | ``{"type": "hyperlink", "value": "..."}`` | Rendered as link or CTA button |
+----------------------+-----------------------------------------------+--------------------------------------------+

Writing a Plugin
----------------

Use ``platzky-promocode`` as the reference implementation
(`source <https://github.com/problematy/platzky-promocode>`_).

The minimum required steps:

1. Subclass ``ContentTransformerPluginBase`` (from ``platzky.plugin.content_transformer``).
2. Declare a ``shortcodes`` class variable mapping shortcode name → ``Shortcode`` instance.
3. Implement a ``Shortcode`` subclass with ``name``, ``attributes``, and ``render()``.
4. Register via a ``pyproject.toml`` entry point in group ``platzky.plugins``.
5. *(Optional)* ship a Webpack Module Federation ``remoteEntry.js`` in your package's
``static/`` directory — Goodmap will serve it automatically and add it to
``PLUGIN_MANIFEST`` (module name must be ``./Button``).

.. code-block:: python

from platzky.plugin.content_transformer import ContentTransformerPluginBase
from platzky.shortcodes.shortcode import Shortcode, ShortcodeAttrs

class _MyShortcode(Shortcode):
name = "myplugin"
description = "My plugin shortcode"
attributes = ShortcodeAttrs([])
example = "[myplugin]value[/myplugin]"

def render(self, attrs: ShortcodeAttrs, content: str) -> str:
return f"<span>{content}</span>"

class MyPlugin(ContentTransformerPluginBase):
shortcodes = {"myplugin": _MyShortcode({})}

def __init__(self, _config):
pass

.. code-block:: toml

# pyproject.toml
[tool.poetry.plugins."platzky.plugins"]
myplugin = "my_package.plugin:MyPlugin"
2 changes: 1 addition & 1 deletion goodmap/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ class GoodmapConfig(PlatzkyConfig):
"""Extended configuration for Goodmap with additional frontend library URL."""

goodmap_frontend_lib_url: str = Field(
default="https://cdn.jsdelivr.net/npm/@problematy/goodmap@1.0.4",
default="https://cdn.jsdelivr.net/npm/@problematy/goodmap@1.6.0",
alias="GOODMAP_FRONTEND_LIB_URL",
)

Expand Down
4 changes: 3 additions & 1 deletion goodmap/core_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@
photo_attachment_class: type[AttachmentProtocol],
photo_attachment_config: AttachmentConfig,
feature_flags: FeatureFlagSet,
field_renderers: dict[str, str] | None = None,
) -> Blueprint:
core_api_blueprint = Blueprint("api", __name__, url_prefix="/api")

Expand Down Expand Up @@ -245,7 +246,7 @@
)
return make_response(jsonify({"message": ERROR_INVALID_LOCATION_DATA}), 400)
except Exception:
logger.error("Error in suggest location endpoint", exc_info=True)

Check failure on line 249 in goodmap/core_api.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use "logging.exception()" instead.

See more on https://sonarcloud.io/project/issues?id=Problematy_goodmap&issues=AZ4dy67iVwbL6j_ecoOz&open=AZ4dy67iVwbL6j_ecoOz&pullRequest=351
return make_response(
jsonify({"message": "An error occurred while processing your suggestion"}), 500
)
Expand Down Expand Up @@ -291,7 +292,7 @@
)
notifier_function(message)
except Exception:
logger.error("Error in report location endpoint", exc_info=True)

Check failure on line 295 in goodmap/core_api.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use "logging.exception()" instead.

See more on https://sonarcloud.io/project/issues?id=Problematy_goodmap&issues=AZ4dy67iVwbL6j_ecoO0&open=AZ4dy67iVwbL6j_ecoO0&pullRequest=351
error_message = gettext("Error sending notification")
return make_response(jsonify({"message": error_message}), 500)
return make_response(jsonify({"message": gettext("Location reported")}), 200)
Expand Down Expand Up @@ -354,7 +355,7 @@
logger.warning("Invalid parameter in clustering request: %s", e)
return make_response(jsonify({"message": "Invalid parameters provided"}), 400)
except Exception as e:
logger.error("Clustering operation failed: %s", e, exc_info=True)

Check failure on line 358 in goodmap/core_api.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use "logging.exception()" instead.

See more on https://sonarcloud.io/project/issues?id=Problematy_goodmap&issues=AZ4dy67iVwbL6j_ecoO1&open=AZ4dy67iVwbL6j_ecoO1&pullRequest=351
return make_response(jsonify({"message": "An error occurred during clustering"}), 500)

@core_api_blueprint.route("/location/<location_id>", methods=["GET"])
Expand All @@ -372,8 +373,9 @@

visible_data = database.get_visible_data()
meta_data = database.get_meta_data()
field_plugins = field_renderers or {}

formatted_data = prepare_pin(location.model_dump(), visible_data, meta_data)
formatted_data = prepare_pin(location.model_dump(), visible_data, meta_data, field_plugins)
return jsonify(formatted_data)

@core_api_blueprint.route("/version", methods=["GET"])
Expand Down
43 changes: 37 additions & 6 deletions goodmap/formatter.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
"""Formatters for translating and preparing location data for display."""

import base64
import logging

from flask_babel import gettext, lazy_gettext

logger = logging.getLogger(__name__)


def safe_gettext(text):
"""Safely apply gettext translation to various data types.
Expand All @@ -20,28 +25,54 @@ def safe_gettext(text):
return gettext(text)


def prepare_pin(place, visible_fields, meta_data):
def _apply_field_plugin(value, field, field_plugins):
"""Wrap a dict field value with its plugin scope if a handler is registered.

Returns:
The wrapped dict with scope if registered, None if the value is an
unconfigured plugin field, or the original value otherwise.
"""
if isinstance(value, dict):
if field in field_plugins:
result = {"scope": field_plugins[field], **value}
if isinstance(result.get("code"), str):
result["code"] = base64.b64encode(result["code"].encode()).decode()
return result
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
if "code" in value and "type" not in value and "scope" not in value:
logger.debug("Dropping field '%s': unconfigured plugin data %s", field, value)
return None
return value


def prepare_pin(place, visible_fields, meta_data, field_plugins=None):
"""Prepare location data for map pin display with translations.

Args:
place: Location data dictionary
visible_fields: List of field names to display in pin
meta_data: List of metadata field names
field_plugins: Optional mapping of field name → plugin scope. Dict-valued
fields listed here are wrapped with ``{"scope": "<name>", ...original_fields}``
so the frontend can route them to the correct plugin component via ``PluginSlot``.

Returns:
dict: Formatted pin data with title, subtitle, position, metadata, and translated fields
"""
plugins = field_plugins or {}
data = []
for field in visible_fields:
if field not in place:
continue
processed = _apply_field_plugin(safe_gettext(place[field]), field, plugins)
if processed is not None:
data.append([gettext(field), processed])
pin_data = {
"title": place["name"],
"subtitle": lazy_gettext(place["type_of_place"]), # TODO this should not be obligatory
"position": place["position"],
"metadata": {
gettext(field): safe_gettext(place[field]) for field in meta_data if field in place
},
"data": [
[gettext(field), safe_gettext(place[field])]
for field in visible_fields
if field in place
],
"data": data,
}
return pin_data
41 changes: 41 additions & 0 deletions goodmap/goodmap.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Goodmap engine with location management and admin interface."""

import importlib.metadata
import inspect
import logging
import os

Expand Down Expand Up @@ -37,7 +39,7 @@
return create_app_from_config(config)


def create_app_from_config(config: GoodmapConfig) -> platzky.Engine:

Check failure on line 42 in goodmap/goodmap.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 18 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=Problematy_goodmap&issues=AZ4dy65MVwbL6j_ecoOy&open=AZ4dy65MVwbL6j_ecoOy&pullRequest=351
"""Create and configure Goodmap application from config object.

Sets up location models, database queries, CSRF protection, API blueprints,
Expand Down Expand Up @@ -87,6 +89,43 @@

app.extensions["goodmap"] = {"location_obligatory_fields": location_obligatory_fields}

field_renderers: dict[str, str] = {}
for sc_name in app.shortcodes:
field_renderers.setdefault(sc_name, sc_name)

plugin_manifest = []
_PLUGIN_ENTRY_POINT_GROUP = "platzky.plugins"
for ep in importlib.metadata.entry_points(group=_PLUGIN_ENTRY_POINT_GROUP):
try:
mod_path = os.path.dirname(os.path.realpath(inspect.getfile(ep.load())))
static_dir = os.path.join(mod_path, "static")
if os.path.isdir(static_dir):
bp = Blueprint(
f"plugin_{ep.name}",
__name__,
url_prefix=f"/plugins/{ep.name}",
static_folder=static_dir,
static_url_path="/static",
)

@bp.after_request
def _add_cors(response):
response.headers["Access-Control-Allow-Origin"] = "*"
return response

app.register_blueprint(bp)
plugin_manifest.append(
{
"scope": ep.name,
"url": f"/plugins/{ep.name}/static/remoteEntry.js",
"module": "./Button",
}
)
Comment thread
raven-wing marked this conversation as resolved.
Outdated
except Exception:
logger.warning("Failed to serve static files for plugin '%s'", ep.name)
Comment thread
raven-wing marked this conversation as resolved.
Outdated

app.config["PLUGIN_MANIFEST"] = plugin_manifest

CSRFProtect(app)

# Create Attachment class for photo uploads
Expand All @@ -108,6 +147,7 @@
photo_attachment_class=PhotoAttachment,
photo_attachment_config=photo_attachment_config,
feature_flags=config.feature_flags,
field_renderers=field_renderers,
)
app.register_blueprint(cp)

Expand Down Expand Up @@ -154,6 +194,7 @@
feature_flags=config.feature_flags,
goodmap_frontend_lib_url=config.goodmap_frontend_lib_url,
location_schema=location_schema,
plugin_manifest=plugin_manifest,
)

@goodmap.route("/goodmap-admin")
Expand Down
4 changes: 2 additions & 2 deletions goodmap/templates/map.html
Original file line number Diff line number Diff line change
Expand Up @@ -120,10 +120,10 @@
window.USE_SERVER_SIDE_CLUSTERING = {{ feature_flags.USE_SERVER_SIDE_CLUSTERING | default(false) | tojson }};
window.SHOW_ACCESSIBILITY_TABLE = {{ feature_flags.SHOW_ACCESSIBILITY_TABLE | default(false) | tojson }};
window.FEATURE_FLAGS = {{ feature_flags | tojson }};

// Location schema for dynamic form building
// Contains required fields and available categories for new location suggestions
globalThis.LOCATION_SCHEMA = {{ location_schema | tojson }};
window.PLUGIN_MANIFEST = {{ plugin_manifest | tojson }};
</script>
<script src="{{ goodmap_frontend_lib_url }}"></script>
<script src="{{ goodmap_frontend_lib_url }}" crossorigin="anonymous"></script>
{% endblock %}
10 changes: 5 additions & 5 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading