diff --git a/.github/workflows/plone-package.yml b/.github/workflows/plone-package.yml
index 5994ad9..7577f41 100644
--- a/.github/workflows/plone-package.yml
+++ b/.github/workflows/plone-package.yml
@@ -16,28 +16,24 @@ jobs:
fail-fast: false
matrix:
plone-version:
- - "Plone52"
- "Plone60"
- "Plone61"
- python-version: [3.8, 3.9, "3.10", "3.11", "3.12", "3.13"]
+ - "Plone62"
+ python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]
exclude:
- - plone-version: "Plone52"
- python-version: 3.9
- - plone-version: "Plone52"
- python-version: 3.10
- - plone-version: "Plone52"
- python-version: 3.11
- - plone-version: "Plone52"
- python-version: 3.12
- - plone-version: "Plone52"
- python-version: 3.13
- plone-version: "Plone60"
- python-version: 3.8
+ python-version: "3.12"
+ - plone-version: "Plone60"
+ python-version: "3.13"
+ - plone-version: "Plone60"
+ python-version: "3.14"
- plone-version: "Plone61"
- python-version: 3.8
+ python-version: "3.9"
- plone-version: "Plone61"
- python-version: 3.9
+ python-version: "3.14"
+ - plone-version: "Plone62"
+ python-version: "3.9"
steps:
- uses: actions/setup-python@v4
@@ -68,12 +64,9 @@ jobs:
run: |
python -m pip install --upgrade pip
pip install tox tox-gh-actions
- - name: Black-Check
- run: |
- tox -r -e black-check
- name: Test with tox
run: |
- tox -r
+ tox
env:
PLONE-VERSION: ${{ matrix.plone-version }}
PYTHON-VERSION: ${{ matrix.python-version }}
diff --git a/README.rst b/README.rst
index e48d13e..a1c54e7 100644
--- a/README.rst
+++ b/README.rst
@@ -24,13 +24,14 @@
cs.srcset
=========
-Backport of the `srcset` method added to the `@@images` view in plone.namedfile 7.1.0 to be able to use it in older Plone versions
+Backport of the `srcset` method added to the `@@images` view in plone.namedfile 7.1.0 to be able to use it in older Plone versions.
+It also includes an optimized `@@image_helper` view for Plone 6+ that uses catalog metadata to avoid N+1 performance issues in listings.
Features
--------
-It adds a view called `@@images-srcset` that has a single method called `srcset` to be able to create an `img` tag with the `srcset` and `sizes`
-attributes to render responsive images.
+- Adds a view called `@@images-srcset` for older Plone versions (backport).
+- Adds a view called `@@image_helper` optimized for Plone 6+ catalog metadata.
Read more about responsive images and its use in the `MDN documentation`_
@@ -38,6 +39,9 @@ Read more about responsive images and its use in the `MDN documentation`_
Documentation
-------------
+@@images-srcset
+~~~~~~~~~~~~~~~
+
You should use this view like this ::
`` tag using only catalog metadata (``image_scales`` attribute in brains), avoiding expensive ``getObject()`` calls.
+If metadata is missing, it gracefully falls back to the standard ``@@images`` view logic.
+
+You should use this view like this ::
+
+
+
+
+
+Available methods:
+
+- ``srcset(item, fieldname='image', **kwargs)``: Returns a responsive ``
`` tag with the ``srcset`` attribute.
+- ``tag(item, fieldname='image', scale=None, **kwargs)``: Returns a fixed ``
`` tag (optionally for a specific scale).
+
+Parameters:
+
+- ``item``: Either a catalog brain (recommended for performance) or a Plone object.
+- ``fieldname``: The name of the image field (default: ``image``).
+- ``scale_in_src``: (Only for the ``srcset`` method) The name of the scale to use for the ``src`` attribute (default: ``huge``).
+- ``scale``: (Only for the ``tag`` method) The name of the scale to use for the ``src``.
+- ``**kwargs``: Any other HTML attributes (``alt``, ``title``, ``loading``, ``css_class``, etc.).
+
+Note: Unlike standard Plone views, this helper does **not** provide default values for ``alt`` or ``loading`` attributes.
+Developers must provide them explicitly in the template if needed.
+However, it **does** automatically provide ``width`` and ``height`` based on the rendered scale to prevent layout shifts.
+
+
diff --git a/buildout.cfg b/buildout.cfg
index cc9a961..4c0f309 100644
--- a/buildout.cfg
+++ b/buildout.cfg
@@ -7,5 +7,6 @@ extends =
# test_plone50.cfg
# test_plone51.cfg
# test_plone52.cfg
- test_plone60.cfg
+# test_plone60.cfg
# test_plone61.cfg
+ test_plone62.cfg
diff --git a/constraints.txt b/constraints.txt
index c1fb3a1..bbad162 100644
--- a/constraints.txt
+++ b/constraints.txt
@@ -1 +1 @@
--c constraints_plone60.txt
+-c constraints_plone62.txt
diff --git a/constraints_plone52.txt b/constraints_plone52.txt
deleted file mode 100644
index 076f8d4..0000000
--- a/constraints_plone52.txt
+++ /dev/null
@@ -1,9 +0,0 @@
--c https://dist.plone.org/release/5.2-latest/requirements.txt
-
-# setuptools==40.2.0
-# zc.buildout==2.13.2
-
-isort>=5.12.0
-black==22.8.0
-tox==4.11.3
-flake8==5.0.4
diff --git a/constraints_plone60.txt b/constraints_plone60.txt
index 067b3b9..54e0ec1 100644
--- a/constraints_plone60.txt
+++ b/constraints_plone60.txt
@@ -12,5 +12,5 @@
#certifi ; platform_system == 'Windows'
tox==4.11.3
isort>=5.12.0
-black==22.8.0
-flake8==5.0.4
+black>=24.4.2
+flake8>=7.0.0
diff --git a/constraints_plone61.txt b/constraints_plone61.txt
index c4e9731..61920c5 100644
--- a/constraints_plone61.txt
+++ b/constraints_plone61.txt
@@ -12,5 +12,5 @@
#certifi ; platform_system == 'Windows'
tox==4.11.3
isort>=5.12.0
-black==22.8.0
-flake8==5.0.4
+black>=24.4.2
+flake8>=7.0.0
diff --git a/constraints_plone62.txt b/constraints_plone62.txt
new file mode 100644
index 0000000..0d12158
--- /dev/null
+++ b/constraints_plone62.txt
@@ -0,0 +1,16 @@
+-c https://dist.plone.org/release/6.2-latest/constraints.txt
+
+#setuptools==54.0.0
+#zc.buildout==3.0.0b2
+#pip==21.0.1
+#
+## Windows specific down here (has to be installed here, fails in buildout)
+## Dependency of zope.sendmail:
+#pywin32 ; platform_system == 'Windows'
+#
+## SSL Certs on windows, because Python is missing them otherwise:
+#certifi ; platform_system == 'Windows'
+tox==4.11.3
+isort>=5.12.0
+black>=24.4.2
+flake8>=7.0.0
diff --git a/docs/conf.py b/docs/conf.py
index f57f79b..c1835b3 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -9,18 +9,18 @@
# All configuration values have a default; values that are commented out
# serve to show the default.
-import sys
import os
+import sys
# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
-#sys.path.insert(0, os.path.abspath('.'))
+# sys.path.insert(0, os.path.abspath('.'))
# -- General configuration ------------------------------------------------
# If your documentation needs a minimal Sphinx version, state it here.
-#needs_sphinx = '1.0'
+# needs_sphinx = '1.0'
# Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
@@ -28,32 +28,32 @@
extensions = []
# Add any paths that contain templates here, relative to this directory.
-templates_path = ['_templates']
+templates_path = ["_templates"]
# The suffix(es) of source filenames.
# You can specify multiple suffix as a list of string:
# source_suffix = ['.rst', '.md']
-source_suffix = '.rst'
+source_suffix = ".rst"
# The encoding of source files.
-#source_encoding = 'utf-8-sig'
+# source_encoding = 'utf-8-sig'
# The master toctree document.
-master_doc = 'index'
+master_doc = "index"
# General information about the project.
-project = u'cs.srcset'
-copyright = u'Mikel Larreategi (codesyntax)'
-author = u'Mikel Larreategi (codesyntax)'
+project = "cs.srcset"
+copyright = "Mikel Larreategi (codesyntax)"
+author = "Mikel Larreategi (codesyntax)"
# The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the
# built documents.
#
# The short X.Y version.
-version = u'3.0'
+version = "3.0"
# The full version, including alpha/beta/rc tags.
-release = u'3.0'
+release = "3.0"
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
@@ -64,38 +64,38 @@
# There are two options for replacing |today|: either, you set today to some
# non-false value, then it is used:
-#today = ''
+# today = ''
# Else, today_fmt is used as the format for a strftime call.
-#today_fmt = '%B %d, %Y'
+# today_fmt = '%B %d, %Y'
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
# This patterns also effect to html_static_path and html_extra_path
-exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
+exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
# The reST default role (used for this markup: `text`) to use for all
# documents.
-#default_role = None
+# default_role = None
# If true, '()' will be appended to :func: etc. cross-reference text.
-#add_function_parentheses = True
+# add_function_parentheses = True
# If true, the current module name will be prepended to all description
# unit titles (such as .. function::).
-#add_module_names = True
+# add_module_names = True
# If true, sectionauthor and moduleauthor directives will be shown in the
# output. They are ignored by default.
-#show_authors = False
+# show_authors = False
# The name of the Pygments (syntax highlighting) style to use.
-pygments_style = 'sphinx'
+pygments_style = "sphinx"
# A list of ignored prefixes for module index sorting.
-#modindex_common_prefix = []
+# modindex_common_prefix = []
# If true, keep warnings as "system message" paragraphs in the built documents.
-#keep_warnings = False
+# keep_warnings = False
# If true, `todo` and `todoList` produce output, else they produce nothing.
todo_include_todos = False
@@ -105,135 +105,132 @@
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
-html_theme = 'alabaster'
+html_theme = "alabaster"
# Theme options are theme-specific and customize the look and feel of a theme
# further. For a list of options available for each theme, see the
# documentation.
-#html_theme_options = {}
+# html_theme_options = {}
# Add any paths that contain custom themes here, relative to this directory.
-#html_theme_path = []
+# html_theme_path = []
# The name for this set of Sphinx documents.
# " v documentation" by default.
-#html_title = u'bobtemplates.plone v3.0'
+# html_title = u'bobtemplates.plone v3.0'
# A shorter title for the navigation bar. Default is the same as html_title.
-#html_short_title = None
+# html_short_title = None
# The name of an image file (relative to this directory) to place at the top
# of the sidebar.
-#html_logo = None
+# html_logo = None
# The name of an image file (relative to this directory) to use as a favicon of
# the docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
# pixels large.
-#html_favicon = None
+# html_favicon = None
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
-html_static_path = ['_static']
+html_static_path = ["_static"]
# Add any extra paths that contain custom files (such as robots.txt or
# .htaccess) here, relative to this directory. These files are copied
# directly to the root of the documentation.
-#html_extra_path = []
+# html_extra_path = []
# If not None, a 'Last updated on:' timestamp is inserted at every page
# bottom, using the given strftime format.
# The empty string is equivalent to '%b %d, %Y'.
-#html_last_updated_fmt = None
+# html_last_updated_fmt = None
# If true, SmartyPants will be used to convert quotes and dashes to
# typographically correct entities.
-#html_use_smartypants = True
+# html_use_smartypants = True
# Custom sidebar templates, maps document names to template names.
-#html_sidebars = {}
+# html_sidebars = {}
# Additional templates that should be rendered to pages, maps page names to
# template names.
-#html_additional_pages = {}
+# html_additional_pages = {}
# If false, no module index is generated.
-#html_domain_indices = True
+# html_domain_indices = True
# If false, no index is generated.
-#html_use_index = True
+# html_use_index = True
# If true, the index is split into individual pages for each letter.
-#html_split_index = False
+# html_split_index = False
# If true, links to the reST sources are added to the pages.
-#html_show_sourcelink = True
+# html_show_sourcelink = True
# If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
-#html_show_sphinx = True
+# html_show_sphinx = True
# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
-#html_show_copyright = True
+# html_show_copyright = True
# If true, an OpenSearch description file will be output, and all pages will
# contain a tag referring to it. The value of this option must be the
# base URL from which the finished HTML is served.
-#html_use_opensearch = ''
+# html_use_opensearch = ''
# This is the file name suffix for HTML files (e.g. ".xhtml").
-#html_file_suffix = None
+# html_file_suffix = None
# Language to be used for generating the HTML full-text search index.
# Sphinx supports the following languages:
# 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja'
# 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr', 'zh'
-#html_search_language = 'en'
+# html_search_language = 'en'
# A dictionary with options for the search language support, empty by default.
# 'ja' uses this config value.
# 'zh' user can custom change `jieba` dictionary path.
-#html_search_options = {'type': 'default'}
+# html_search_options = {'type': 'default'}
# The name of a javascript file (relative to the configuration directory) that
# implements a search results scorer. If empty, the default will be used.
-#html_search_scorer = 'scorer.js'
+# html_search_scorer = 'scorer.js'
# Output file base name for HTML help builder.
-htmlhelp_basename = 'cs.srcsetdoc'
+htmlhelp_basename = "cs.srcsetdoc"
# -- Options for LaTeX output ---------------------------------------------
latex_elements = {
-# The paper size ('letterpaper' or 'a4paper').
-#'papersize': 'letterpaper',
-
-# The font size ('10pt', '11pt' or '12pt').
-#'pointsize': '10pt',
-
-# Additional stuff for the LaTeX preamble.
-#'preamble': '',
-
-# Latex figure (float) alignment
-#'figure_align': 'htbp',
+ # The paper size ('letterpaper' or 'a4paper').
+ #'papersize': 'letterpaper',
+ # The font size ('10pt', '11pt' or '12pt').
+ #'pointsize': '10pt',
+ # Additional stuff for the LaTeX preamble.
+ #'preamble': '',
+ # Latex figure (float) alignment
+ #'figure_align': 'htbp',
}
# The name of an image file (relative to this directory) to place at the top of
# the title page.
-#latex_logo = None
+# latex_logo = None
# For "manual" documents, if this is true, then toplevel headings are parts,
# not chapters.
-#latex_use_parts = False
+# latex_use_parts = False
# If true, show page references after internal links.
-#latex_show_pagerefs = False
+# latex_show_pagerefs = False
# If true, show URL addresses after external links.
-#latex_show_urls = False
+# latex_show_urls = False
# Documents to append as an appendix to all manuals.
-#latex_appendices = []
+# latex_appendices = []
# If false, no module index is generated.
-#latex_domain_indices = True
+# latex_domain_indices = True
diff --git a/news/2.breaking b/news/2.breaking
new file mode 100644
index 0000000..bce5d7c
--- /dev/null
+++ b/news/2.breaking
@@ -0,0 +1 @@
+Remove Plone 5.2 and Python 3.7/3.8 as unsupported. @erral
diff --git a/news/2.feature b/news/2.feature
new file mode 100644
index 0000000..f8a9598
--- /dev/null
+++ b/news/2.feature
@@ -0,0 +1 @@
+Add a @@image-helper view to create image URLs from catalog metadata @erral
diff --git a/pyproject.toml b/pyproject.toml
index 3c2d546..a8a4f06 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,3 +1,15 @@
[build-system]
requires = ["setuptools>=68.2,<=75.8.0"]
build-backend = "setuptools.build_meta"
+
+[tool.towncrier]
+directory = "news/"
+filename = "CHANGES.rst"
+title_format = "{version} (unreleased)"
+underlines = ["", "-"]
+issue_format = "[#{issue}](https://github.com/codesyntax/cs.srcset/pull/{issue})"
+type = [
+ { directory = "breaking", name = "Breaking changes", showcontent = true },
+ { directory = "feature", name = "New features", showcontent = true },
+ { directory = "bugfix", name = "Bug fixes", showcontent = true },
+]
diff --git a/requirements.txt b/requirements.txt
index 0ba7f38..65c13b7 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1 +1 @@
--r requirements_plone60.txt
+-r requirements_plone62.txt
diff --git a/requirements_plone52.txt b/requirements_plone52.txt
deleted file mode 100644
index fa2f614..0000000
--- a/requirements_plone52.txt
+++ /dev/null
@@ -1,3 +0,0 @@
--c constraints_plone52.txt
-setuptools
-zc.buildout
diff --git a/requirements_plone62.txt b/requirements_plone62.txt
new file mode 100644
index 0000000..2a0bd65
--- /dev/null
+++ b/requirements_plone62.txt
@@ -0,0 +1,3 @@
+-c constraints_plone62.txt
+setuptools
+zc.buildout
diff --git a/setup.py b/setup.py
index 441285c..04a29b9 100644
--- a/setup.py
+++ b/setup.py
@@ -4,7 +4,6 @@
from setuptools import find_packages
from setuptools import setup
-
long_description = "\n\n".join(
[
open("README.rst").read(),
@@ -24,14 +23,16 @@
"Environment :: Web Environment",
"Framework :: Plone",
"Framework :: Plone :: Addon",
- "Framework :: Plone :: 5.2",
"Framework :: Plone :: 6.0",
+ "Framework :: Plone :: 6.1",
+ "Framework :: Plone :: 6.2",
"Programming Language :: Python",
- "Programming Language :: Python :: 3.7",
- "Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
+ "Programming Language :: Python :: 3.12",
+ "Programming Language :: Python :: 3.13",
+ "Programming Language :: Python :: 3.14",
"Operating System :: OS Independent",
"License :: OSI Approved :: GNU General Public License v2 (GPLv2)",
],
@@ -51,7 +52,7 @@
package_dir={"": "src"},
include_package_data=True,
zip_safe=False,
- python_requires=">=3.7",
+ python_requires=">=3.9",
install_requires=[
"setuptools",
# -*- Extra requirements: -*-
diff --git a/src/cs/srcset/__init__.py b/src/cs/srcset/__init__.py
index 8911a51..5d0135f 100644
--- a/src/cs/srcset/__init__.py
+++ b/src/cs/srcset/__init__.py
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""Init and utils."""
-from zope.i18nmessageid import MessageFactory
+from zope.i18nmessageid import MessageFactory
_ = MessageFactory("cs.srcset")
diff --git a/src/cs/srcset/configure.zcml b/src/cs/srcset/configure.zcml
index 2221680..d6d18e6 100644
--- a/src/cs/srcset/configure.zcml
+++ b/src/cs/srcset/configure.zcml
@@ -18,4 +18,11 @@
permission="zope2.View"
/>
+
+
diff --git a/src/cs/srcset/helper.py b/src/cs/srcset/helper.py
new file mode 100644
index 0000000..dc34170
--- /dev/null
+++ b/src/cs/srcset/helper.py
@@ -0,0 +1,140 @@
+from Products.Five.browser import BrowserView
+from zope.interface import implementer
+from zope.interface import Interface
+
+
+class IImageHelper(Interface):
+ """Marker interface for ImageHelper"""
+
+
+@implementer(IImageHelper)
+class ImageHelper(BrowserView):
+ def srcset(self, item, fieldname="image", scale_in_src="huge", **kwargs):
+ """Generate srcset img tag from brain metadata or fallback to getObject()."""
+ kwargs["scale_in_src"] = scale_in_src
+ return self._render("srcset", item, fieldname, **kwargs)
+
+ def tag(self, item, fieldname="image", scale=None, **kwargs):
+ """Generate fixed img tag from brain metadata or fallback to getObject()."""
+ kwargs["scale"] = scale
+ return self._render("tag", item, fieldname, **kwargs)
+
+ def _render(self, method_name, item, fieldname, **kwargs):
+ # Try to use metadata if available
+ if hasattr(item, "image_scales"):
+ image_scales = getattr(item, "image_scales", None)
+ if image_scales and fieldname in image_scales:
+ field_data = image_scales[fieldname]
+ if isinstance(field_data, list) and len(field_data) > 0:
+ data = field_data[0]
+ if method_name == "srcset":
+ return self._generate_srcset_tag(item, data, **kwargs)
+ else:
+ return self._generate_fixed_tag(item, data, **kwargs)
+
+ # Eager Fallback
+ obj = item.getObject() if hasattr(item, "getObject") else item
+ try:
+ scales = obj.restrictedTraverse("@@images")
+ if hasattr(scales, method_name):
+ method = getattr(scales, method_name)
+ # Remove internal helper params before passing to @@images
+ call_kwargs = kwargs.copy()
+ call_kwargs.pop("scale_in_src", None)
+ res = method(fieldname, **call_kwargs)
+ return res if res is not None else ""
+
+ # If @@images doesn't have it, try our own backport view
+ if method_name == "srcset":
+ backport = obj.restrictedTraverse("@@images-srcset")
+ return backport.srcset(fieldname, **kwargs)
+ except Exception:
+ return ""
+
+ def _generate_srcset_tag(self, item, data, **kwargs):
+ """Manually construct the srcset
tag from brain metadata."""
+ base_url = item.getURL() if hasattr(item, "getURL") else item.absolute_url()
+ if callable(base_url):
+ base_url = base_url()
+
+ scales = data.get("scales", {})
+
+ # Determine src scale
+ scale_in_src = kwargs.pop("scale_in_src", "huge")
+ if scale_in_src in scales:
+ scale_info = scales[scale_in_src]
+ src_url = f"{base_url}/{scale_info['download']}"
+ width = scale_info.get("width")
+ height = scale_info.get("height")
+ else:
+ src_url = f"{base_url}/{data['download']}"
+ width = data.get("width")
+ height = data.get("height")
+
+ srcset_parts = []
+ sorted_scales = sorted(scales.items(), key=lambda x: x[1].get("width", 0))
+ for _, scale_info in sorted_scales:
+ scale_url = f"{base_url}/{scale_info['download']}"
+ swidth = scale_info.get("width")
+ if swidth:
+ srcset_parts.append(f"{scale_url} {swidth}w")
+
+ srcset = ", ".join(srcset_parts)
+
+ # Merge parameters
+ merged = kwargs.copy()
+ if "width" not in merged:
+ merged["width"] = width
+ if "height" not in merged:
+ merged["height"] = height
+
+ return self._build_tag(src_url, srcset=srcset, **merged)
+
+ def _generate_fixed_tag(self, item, data, **kwargs):
+ """Manually construct a fixed
tag from brain metadata."""
+ base_url = item.getURL() if hasattr(item, "getURL") else item.absolute_url()
+ if callable(base_url):
+ base_url = base_url()
+
+ scales = data.get("scales", {})
+
+ # If a specific scale is requested via scale parameter
+ scale_name = kwargs.pop("scale", None)
+ if scale_name and scale_name in scales:
+ scale_info = scales[scale_name]
+ src_url = f"{base_url}/{scale_info['download']}"
+ width = scale_info.get("width")
+ height = scale_info.get("height")
+ else:
+ # Fallback to original
+ src_url = f"{base_url}/{data['download']}"
+ width = data.get("width")
+ height = data.get("height")
+
+ # Merge parameters
+ merged = kwargs.copy()
+ if "width" not in merged:
+ merged["width"] = width
+ if "height" not in merged:
+ merged["height"] = height
+
+ return self._build_tag(src_url, **merged)
+
+ def _build_tag(self, src, srcset=None, **kwargs):
+ """Helper to build the
tag string."""
+ tag = f'
"
+ return tag
diff --git a/src/cs/srcset/locales/update.py b/src/cs/srcset/locales/update.py
index 8e7d36a..39c915f 100644
--- a/src/cs/srcset/locales/update.py
+++ b/src/cs/srcset/locales/update.py
@@ -4,7 +4,6 @@
import pkg_resources
import subprocess
-
domain = "cs.srcset"
os.chdir(pkg_resources.resource_filename(domain, ""))
os.chdir("../../../")
diff --git a/src/cs/srcset/testing.py b/src/cs/srcset/testing.py
index bbdd2ea..864ae36 100644
--- a/src/cs/srcset/testing.py
+++ b/src/cs/srcset/testing.py
@@ -4,7 +4,14 @@
from plone.app.testing import IntegrationTesting
from plone.app.testing import PLONE_FIXTURE
from plone.app.testing import PloneSandboxLayer
-from plone.testing import z2
+
+try:
+ from plone.testing import zope as z2
+except ImportError:
+ try:
+ from plone.testing import z2
+ except ImportError:
+ z2 = None
import cs.srcset
@@ -17,12 +24,20 @@ def setUpZope(self, app, configurationContext):
# Load any other ZCML that is required for your tests.
# The z3c.autoinclude feature is disabled in the Plone fixture base
# layer.
+ import plone.app.contenttypes
import plone.app.dexterity
self.loadZCML(package=plone.app.dexterity)
+ self.loadZCML(package=plone.app.contenttypes)
self.loadZCML(package=cs.srcset)
+ if z2 is not None:
+ z2.installProduct(app, "plone.app.contenttypes")
+
+ def setUpPloneSite(self, portal):
+ applyProfile(portal, "plone.app.contenttypes:default")
+
CS_SRCSET_FIXTURE = CsSrcsetLayer()
diff --git a/src/cs/srcset/tests/test_helper.py b/src/cs/srcset/tests/test_helper.py
new file mode 100644
index 0000000..091e662
--- /dev/null
+++ b/src/cs/srcset/tests/test_helper.py
@@ -0,0 +1,178 @@
+from cs.srcset.testing import CS_SRCSET_INTEGRATION_TESTING
+from plone import api
+from plone.app.testing import setRoles
+from plone.app.testing import TEST_USER_ID
+from plone.dexterity.content import Item
+from plone.dexterity.fti import DexterityFTI
+from plone.dexterity.interfaces import IDexterityFTI
+from plone.namedfile import field as namedfile
+from plone.namedfile.file import NamedImage
+from plone.namedfile.tests import getFile
+from plone.supermodel import model
+from zope.component import getSiteManager
+from zope.component.factory import Factory
+from zope.component.interfaces import IFactory
+
+import unittest
+
+
+class ICustomImage(model.Schema):
+ photo = namedfile.NamedImage(title="Photo", required=False)
+ logo = namedfile.NamedImage(title="Logo", required=False)
+
+
+class TestImageHelper(unittest.TestCase):
+
+ layer = CS_SRCSET_INTEGRATION_TESTING
+
+ def setUp(self):
+ self.portal = self.layer["portal"]
+ self.request = self.layer["request"]
+ setRoles(self.portal, TEST_USER_ID, ["Manager"])
+
+ # Create a News Item
+ self.portal.invokeFactory("News Item", "news1", title="News 1")
+ self.news1 = self.portal.news1
+
+ # Add an image to the news item
+ image_data = getFile("image.png")
+ self.news1.image = NamedImage(image_data, "image/png", "image.png")
+ self.news1.reindexObject()
+
+ # Get the helper view
+ self.helper = api.content.get_view("image_helper", self.portal, self.request)
+
+ def test_news_item_srcset_brain(self):
+ """Test srcset with a News Item brain."""
+ brains = api.content.find(id="news1")
+ self.assertEqual(len(brains), 1)
+ brain = brains[0]
+
+ tag = self.helper.srcset(
+ brain, fieldname="image", sizes="50vw", css_class="my-news-img"
+ )
+
+ # Assertions on generated tag
+ # By default no alt, no loading
+ self.assertIn('src="http://nohost/plone/news1/@@images/image', tag)
+ self.assertIn('srcset="', tag)
+ self.assertIn('sizes="50vw"', tag)
+ self.assertIn('class="my-news-img"', tag)
+ self.assertNotIn('alt="', tag)
+ self.assertNotIn('loading="', tag)
+ # width and height should be present
+ self.assertIn('width="', tag)
+ self.assertIn('height="', tag)
+
+ def test_news_item_srcset_with_explicit_attributes(self):
+ """Test srcset with explicit alt and loading attributes."""
+ brains = api.content.find(id="news1")
+ brain = brains[0]
+
+ tag = self.helper.srcset(
+ brain,
+ fieldname="image",
+ alt="My Alt",
+ loading="eager",
+ title="My Title",
+ )
+
+ self.assertIn('alt="My Alt"', tag)
+ self.assertIn('loading="eager"', tag)
+ self.assertIn('title="My Title"', tag)
+
+ def test_news_item_srcset_object(self):
+ """Test srcset with a News Item object (fallback path)."""
+ tag = self.helper.srcset(self.news1, fieldname="image", sizes="50vw")
+
+ self.assertIn('src="http://nohost/plone/news1/@@images/image', tag)
+ self.assertIn('srcset="', tag)
+ self.assertIn('sizes="50vw"', tag)
+
+ def test_news_item_tag_brain(self):
+ """Test tag method with a News Item brain."""
+ brains = api.content.find(id="news1")
+ brain = brains[0]
+
+ tag = self.helper.tag(
+ brain, fieldname="image", scale="thumb", css_class="thumb-img"
+ )
+
+ self.assertIn('class="thumb-img"', tag)
+ self.assertIn("/@@images/image", tag)
+ # Should contain height and width from metadata
+ self.assertIn('width="', tag)
+ self.assertIn('height="', tag)
+
+ def test_missing_image(self):
+ """Test with an item that has no image."""
+ self.portal.invokeFactory("News Item", "news2", title="News 2")
+ news2 = self.portal.news2
+ news2.reindexObject()
+
+ brains = api.content.find(id="news2")
+ brain = brains[0]
+
+ tag = self.helper.srcset(brain, fieldname="image")
+ self.assertEqual(tag, "")
+
+ def test_invalid_fieldname(self):
+ """Test with an invalid fieldname."""
+ brains = api.content.find(id="news1")
+ brain = brains[0]
+
+ tag = self.helper.srcset(brain, fieldname="nonexistent")
+ self.assertEqual(tag, "")
+
+ def test_custom_content_type(self):
+ """Test with a custom content type with different image field names."""
+ # Programmatically create a simple dexterity type
+ fti = DexterityFTI("CustomType")
+ fti.schema = "cs.srcset.tests.test_helper.ICustomImage"
+ fti.klass = "plone.dexterity.content.Item"
+ self.portal.portal_types._setOb("CustomType", fti)
+
+ # Register FTI as utility
+ sm = getSiteManager()
+ sm.registerUtility(fti, IDexterityFTI, name="CustomType")
+
+ # Register factory
+ sm.registerUtility(
+ Factory(Item, title="Custom Type"), IFactory, name="CustomType"
+ )
+
+ self.portal.invokeFactory("CustomType", "custom1", title="Custom 1")
+ custom1 = self.portal.custom1
+
+ image_data = getFile("image.png")
+ custom1.photo = NamedImage(image_data, "image/png", "photo.png")
+ custom1.logo = NamedImage(image_data, "image/png", "logo.png")
+ custom1.reindexObject()
+
+ brains = api.content.find(id="custom1")
+ brain = brains[0]
+
+ # Test photo field
+ tag_photo = self.helper.srcset(brain, fieldname="photo", sizes="20vw")
+ self.assertIn('srcset="', tag_photo)
+ self.assertIn("photo", tag_photo)
+
+ # Test logo field
+ tag_logo = self.helper.srcset(brain, fieldname="logo", sizes="10vw")
+ self.assertIn('srcset="', tag_logo)
+ self.assertIn("logo", tag_logo)
+
+ def test_srcset_scale_in_src(self):
+ """Test selecting a specific scale for the src attribute in srcset."""
+ brains = api.content.find(id="news1")
+ brain = brains[0]
+
+ # If 'huge' is available in metadata, it should use it.
+ # News Item LeadImage behavior usually has 'huge' scale.
+ tag = self.helper.srcset(brain, fieldname="image", scale_in_src="teaser")
+ self.assertIn("/@@images/image-", tag)
+ self.assertIn('src="http://nohost/plone/news1/@@images/image', tag)
+ # We can check if dimensions match teaser if we knew them,
+ # but at least check it doesn't crash and has width/height.
+ self.assertIn('width="', tag)
+ self.assertIn('height="', tag)
diff --git a/src/cs/srcset/tests/test_view.py b/src/cs/srcset/tests/test_view.py
index 477b8cf..6bb8feb 100644
--- a/src/cs/srcset/tests/test_view.py
+++ b/src/cs/srcset/tests/test_view.py
@@ -1,18 +1,20 @@
-import unittest
-from doctest import _ellipsis_match
-
from cs.srcset.testing import CS_SRCSET_INTEGRATION_TESTING
from cs.srcset.view import SrcSetView
from DateTime import DateTime
+from doctest import _ellipsis_match
from OFS.SimpleItem import SimpleItem
from plone.namedfile.field import NamedImage as NamedImageField
from plone.namedfile.interfaces import IImageScaleTraversable
-from plone.namedfile.tests import MockNamedImage, getFile
+from plone.namedfile.tests import getFile
+from plone.namedfile.tests import MockNamedImage
from plone.rfc822.interfaces import IPrimaryFieldInfo
from zope.annotation import IAttributeAnnotatable
-from zope.component import adapter, getSiteManager
+from zope.component import adapter
+from zope.component import getSiteManager
from zope.interface import implementer
+import unittest
+
class IHasImage(IImageScaleTraversable):
image = NamedImageField()
diff --git a/src/cs/srcset/view.py b/src/cs/srcset/view.py
index c7a5440..ddc3272 100644
--- a/src/cs/srcset/view.py
+++ b/src/cs/srcset/view.py
@@ -14,7 +14,6 @@
import functools
import logging
-
logger = logging.getLogger(__name__)
_marker = object()
diff --git a/test_plone52.cfg b/test_plone62.cfg
similarity index 56%
rename from test_plone52.cfg
rename to test_plone62.cfg
index ed1d481..7bedaf1 100644
--- a/test_plone52.cfg
+++ b/test_plone62.cfg
@@ -1,13 +1,12 @@
[buildout]
extends =
- https://raw.githubusercontent.com/collective/buildout.plonetest/master/test-5.2.x.cfg
+ https://raw.githubusercontent.com/collective/buildout.plonetest/master/test-6.2.x.cfg
https://raw.githubusercontent.com/collective/buildout.plonetest/master/qa.cfg
base.cfg
-update-versions-file = test_plone52.cfg
+update-versions-file = test_plone62.cfg
[versions]
-plone.testing = 7.0.1
-collective.recipe.vscode = >=0.1.6
-importlib-metadata = 1.1.3
+createcoverage = 1.5
+watchdog = 2.1.6
diff --git a/tox.ini b/tox.ini
index 51cf8c1..1e38b5c 100644
--- a/tox.ini
+++ b/tox.ini
@@ -3,14 +3,14 @@
min_version = 4.11.0
envlist =
- py38-lint,
py39-lint,
py310-lint,
py311-lint,
+ py313-lint,
black-check,
- py{38}-Plone{52},
py{39,310,311,312,313}-Plone{60},
py{310,311,312,313}-Plone{61},
+ py{310,311,312,313,314}-Plone{62},
# docs,
# coverage-report,
@@ -19,19 +19,20 @@ skip_missing_interpreters = True
[gh-actions]
python =
- 3.8: py38
- 3.9: py39
- 3.10: py310
- 3.11: py311
- 3.12: py312
- 3.13: py313
+ 3.9: py39, black-check
+ 3.10: py310, black-check
+ 3.11: py311, black-check
+ 3.12: py312, black-check
+ 3.13: py313, black-check
+ 3.14: py314, black-check
+
[gh-actions:env]
PLONE-VERSION =
- Plone52: Plone52
Plone60: Plone60
Plone61: Plone61
+ Plone62: Plone62
[testenv]
@@ -42,8 +43,6 @@ extras =
test
commands =
- {envbindir}/buildout -q -c {toxinidir}/{env:version_file} buildout:directory={envdir} buildout:develop={toxinidir} bootstrap
-# {envbindir}/buildout -c {toxinidir}/{env:version_file} buildout:directory={envdir} buildout:develop={toxinidir} annotate
{envbindir}/buildout -n -qq -c {toxinidir}/{env:version_file} buildout:directory={envdir} buildout:develop={toxinidir} install test robot
coverage run {envbindir}/test -v1 --auto-color {posargs}
# coverage run {envbindir}/test -v --all -t robot {posargs}
@@ -51,28 +50,25 @@ commands =
setenv =
COVERAGE_FILE=.coverage.{envname}
# version_file=test_plone60.cfg
- Plone52: version_file=test_plone52.cfg
Plone60: version_file=test_plone60.cfg
Plone61: version_file=test_plone61.cfg
+ Plone62: version_file=test_plone62.cfg
deps =
- Plone52: -rrequirements_plone52.txt
-# Plone52: -cconstraints_plone52.txt
Plone60: -rrequirements_plone60.txt
# Plone60: -cconstraints_plone60.txt
Plone61: -rrequirements_plone61.txt
# Plone61: -cconstraints_plone61.txt
+ Plone62: -rrequirements_plone62.txt
coverage
[testenv:coverage-report]
skip_install = true
usedevelop = True
-basepython = python3
deps =
coverage
- -cconstraints_plone60.txt
setenv =
COVERAGE_FILE=.coverage
@@ -89,7 +85,6 @@ commands =
skip_install = true
deps =
- -cconstraints.txt
isort
flake8
# helper to generate HTML reports:
@@ -128,7 +123,6 @@ allowlist_externals =
skip_install = true
deps =
- -cconstraints.txt
isort
commands =
@@ -136,10 +130,8 @@ commands =
[testenv:black-check]
-basepython = python3
skip_install = True
deps =
- -cconstraints.txt
black
commands =
@@ -147,23 +139,14 @@ commands =
[testenv:black-enforce]
-basepython = python3
skip_install = True
deps =
- -cconstraints.txt
black
commands =
black -v src setup.py
-[testenv:py38-lint]
-basepython = python3.8
-skip_install = true
-deps = {[lint]deps}
-commands = {[lint]commands}
-allowlist_externals = {[lint]allowlist_externals}
-
[testenv:py39-lint]
basepython = python3.9
skip_install = true
@@ -185,6 +168,13 @@ deps = {[lint]deps}
commands = {[lint]commands}
allowlist_externals = {[lint]allowlist_externals}
+[testenv:py313-lint]
+basepython = python3.13
+skip_install = true
+deps = {[lint]deps}
+commands = {[lint]commands}
+allowlist_externals = {[lint]allowlist_externals}
+
[testenv:docs]
skip_install = true