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'