├── news └── .gitkeep ├── src └── plone │ └── app │ └── theming │ ├── browser │ ├── __init__.py │ ├── icon.gif │ ├── resources │ │ ├── preview.png │ │ ├── defaultPreview.png │ │ └── controlpanel.css │ ├── theme-error.pt │ ├── help.py │ ├── themefile.py │ ├── custom_css.py │ ├── configure.zcml │ └── controlpanel.py │ ├── tests │ ├── __init__.py │ ├── resources │ │ ├── resource.css │ │ ├── resource.js │ │ ├── nonascii.html │ │ ├── manifest.cfg │ │ ├── othertheme.html │ │ ├── theme.html │ │ ├── overridestheme.html │ │ ├── nonascii.xml │ │ ├── css-js.xml │ │ ├── overridesrules.xml │ │ └── rules.xml │ ├── package_theme.txt │ ├── zipfiles │ │ ├── nodir.zip │ │ ├── default_rules.zip │ │ ├── multiple_dir.zip │ │ ├── manifest_prefix.zip │ │ ├── manifest_preview.zip │ │ ├── manifest_rules.zip │ │ ├── subdirectories.zip │ │ ├── manifest_default_rules.zip │ │ ├── ignores_dotfiles_resource_forks.zip │ │ └── manifest_default_rules_override.zip │ ├── one.html │ ├── two.html │ ├── browser.py │ ├── nonascii.html │ ├── includes.html │ ├── othertheme.html │ ├── theme.html │ ├── notheme.pt │ ├── another-theme │ │ ├── rules.xml │ │ └── manifest.cfg │ ├── secondary-theme │ │ ├── rules.xml │ │ └── manifest.cfg │ ├── french.html │ ├── nonascii.xml │ ├── localrules.xml │ ├── otherrules.xml │ ├── includes.xml │ ├── rules.xml │ ├── configure.zcml │ ├── paramrules.xml │ ├── test_controlpanel.py │ ├── test_exportimport.py │ └── test_policy.py │ ├── exportimport │ ├── __init__.py │ ├── configure.zcml │ └── handler.py │ ├── themes │ └── template │ │ ├── manifest.cfg │ │ ├── index.html │ │ └── rules.xml │ ├── plugins │ ├── __init__.py │ ├── configure.zcml │ ├── hooks.py │ └── utils.py │ ├── __init__.py │ ├── profiles │ └── default │ │ ├── registry.xml │ │ ├── browserlayer.xml │ │ ├── metadata.xml │ │ └── controlpanel.xml │ ├── events.py │ ├── themes.zcml │ ├── header.py │ ├── upgrade.py │ ├── traversal.py │ ├── testing.py │ ├── zmi.py │ ├── theme.py │ ├── configure.zcml │ ├── policy.py │ ├── transform.py │ ├── interfaces.py │ └── utils.py ├── requirements.txt ├── CONTRIBUTING.rst ├── resources └── theme │ └── theme1 │ ├── views │ ├── name-view.pt │ ├── test-view.pt │ ├── context-view.pt │ ├── permission-view.pt │ ├── views.cfg │ └── class-view.pt │ ├── othertheme.html │ ├── manifest.cfg │ ├── theme.html │ ├── overrides │ └── plone.app.layout.viewlets.colophon.pt │ └── rules.xml ├── mx.ini ├── MANIFEST.in ├── README.rst ├── versions.cfg ├── .github ├── dependabot.yml └── workflows │ ├── meta.yml │ └── test-matrix.yml ├── .meta.toml ├── .flake8 ├── TODO.txt ├── LICENSE ├── .gitignore ├── .editorconfig ├── .pre-commit-config.yaml ├── setup.py ├── pyproject.toml ├── tox.ini └── CHANGES.rst /news/.gitkeep: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/plone/app/theming/browser/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/plone/app/theming/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/plone/app/theming/exportimport/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/plone/app/theming/tests/resources/resource.css: -------------------------------------------------------------------------------- 1 | /* A CSS file */ 2 | -------------------------------------------------------------------------------- /src/plone/app/theming/tests/resources/resource.js: -------------------------------------------------------------------------------- 1 | /* A JS file */ 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -c https://dist.plone.org/release/6.2-dev/constraints.txt 2 | -------------------------------------------------------------------------------- /src/plone/app/theming/themes/template/manifest.cfg: -------------------------------------------------------------------------------- 1 | [theme] 2 | title = 3 | description = -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | Please see http://docs.plone.org/develop/coredev/docs/guidelines.html 2 | -------------------------------------------------------------------------------- /src/plone/app/theming/plugins/__init__.py: -------------------------------------------------------------------------------- 1 | __import__("pkg_resources").declare_namespace(__name__) 2 | -------------------------------------------------------------------------------- /src/plone/app/theming/tests/package_theme.txt: -------------------------------------------------------------------------------- 1 | This can be loaded with the python package resolver. 2 | -------------------------------------------------------------------------------- /src/plone/app/theming/tests/resources/nonascii.html: -------------------------------------------------------------------------------- 1 | 2 |
Número uno
3 | 4 | -------------------------------------------------------------------------------- /src/plone/app/theming/browser/icon.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plone/plone.app.theming/HEAD/src/plone/app/theming/browser/icon.gif -------------------------------------------------------------------------------- /src/plone/app/theming/tests/zipfiles/nodir.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plone/plone.app.theming/HEAD/src/plone/app/theming/tests/zipfiles/nodir.zip -------------------------------------------------------------------------------- /src/plone/app/theming/browser/resources/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plone/plone.app.theming/HEAD/src/plone/app/theming/browser/resources/preview.png -------------------------------------------------------------------------------- /src/plone/app/theming/tests/zipfiles/default_rules.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plone/plone.app.theming/HEAD/src/plone/app/theming/tests/zipfiles/default_rules.zip -------------------------------------------------------------------------------- /src/plone/app/theming/tests/zipfiles/multiple_dir.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plone/plone.app.theming/HEAD/src/plone/app/theming/tests/zipfiles/multiple_dir.zip -------------------------------------------------------------------------------- /src/plone/app/theming/tests/zipfiles/manifest_prefix.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plone/plone.app.theming/HEAD/src/plone/app/theming/tests/zipfiles/manifest_prefix.zip -------------------------------------------------------------------------------- /src/plone/app/theming/tests/zipfiles/manifest_preview.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plone/plone.app.theming/HEAD/src/plone/app/theming/tests/zipfiles/manifest_preview.zip -------------------------------------------------------------------------------- /src/plone/app/theming/tests/zipfiles/manifest_rules.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plone/plone.app.theming/HEAD/src/plone/app/theming/tests/zipfiles/manifest_rules.zip -------------------------------------------------------------------------------- /src/plone/app/theming/tests/zipfiles/subdirectories.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plone/plone.app.theming/HEAD/src/plone/app/theming/tests/zipfiles/subdirectories.zip -------------------------------------------------------------------------------- /src/plone/app/theming/browser/resources/defaultPreview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plone/plone.app.theming/HEAD/src/plone/app/theming/browser/resources/defaultPreview.png -------------------------------------------------------------------------------- /src/plone/app/theming/tests/one.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Title 4 | 5 | 6 |
Number one
7 | 8 | -------------------------------------------------------------------------------- /src/plone/app/theming/tests/two.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Title 4 | 5 | 6 |
Number two
7 | 8 | -------------------------------------------------------------------------------- /resources/theme/theme1/views/name-view.pt: -------------------------------------------------------------------------------- 1 | 2 | 3 |

Name view

4 |
title here
5 | 6 | 7 | -------------------------------------------------------------------------------- /resources/theme/theme1/views/test-view.pt: -------------------------------------------------------------------------------- 1 | 2 | 3 |

Test view

4 |
title here
5 | 6 | 7 | -------------------------------------------------------------------------------- /src/plone/app/theming/tests/browser.py: -------------------------------------------------------------------------------- 1 | from Products.Five import BrowserView 2 | 3 | 4 | class Title(BrowserView): 5 | def __call__(self): 6 | return self.context.Title() 7 | -------------------------------------------------------------------------------- /src/plone/app/theming/tests/nonascii.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Title 4 | 5 | 6 |
(placeholder)
7 | 8 | -------------------------------------------------------------------------------- /src/plone/app/theming/tests/zipfiles/manifest_default_rules.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plone/plone.app.theming/HEAD/src/plone/app/theming/tests/zipfiles/manifest_default_rules.zip -------------------------------------------------------------------------------- /resources/theme/theme1/views/context-view.pt: -------------------------------------------------------------------------------- 1 | 2 | 3 |

Context view

4 |
title here
5 | 6 | 7 | -------------------------------------------------------------------------------- /resources/theme/theme1/views/permission-view.pt: -------------------------------------------------------------------------------- 1 | 2 | 3 |

Permission view

4 |
title here
5 | 6 | 7 | -------------------------------------------------------------------------------- /src/plone/app/theming/__init__.py: -------------------------------------------------------------------------------- 1 | # make this a namespace packages (plone.app.theming.plugins is an 2 | # extensible python namespace 3 | __import__("pkg_resources").declare_namespace(__name__) 4 | -------------------------------------------------------------------------------- /src/plone/app/theming/profiles/default/registry.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/plone/app/theming/tests/zipfiles/ignores_dotfiles_resource_forks.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plone/plone.app.theming/HEAD/src/plone/app/theming/tests/zipfiles/ignores_dotfiles_resource_forks.zip -------------------------------------------------------------------------------- /src/plone/app/theming/tests/zipfiles/manifest_default_rules_override.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plone/plone.app.theming/HEAD/src/plone/app/theming/tests/zipfiles/manifest_default_rules_override.zip -------------------------------------------------------------------------------- /src/plone/app/theming/tests/resources/manifest.cfg: -------------------------------------------------------------------------------- 1 | [theme] 2 | title = Test theme 3 | description = A theme for testing 4 | doctype = 5 | 6 | [theme:parameters] 7 | foo = python:request.get('bar') 8 | -------------------------------------------------------------------------------- /src/plone/app/theming/tests/includes.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Title 4 | 5 | 6 |
(placeholder)
7 |
(placeholder)
8 | 9 | -------------------------------------------------------------------------------- /resources/theme/theme1/othertheme.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Title 4 | 5 | 6 |

Page title

7 |

This is the other theme.

8 | 9 | 10 | -------------------------------------------------------------------------------- /src/plone/app/theming/profiles/default/browserlayer.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | -------------------------------------------------------------------------------- /src/plone/app/theming/tests/othertheme.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Title 4 | 5 | 6 |

Page title

7 |

This is the other theme.

8 | 9 | 10 | -------------------------------------------------------------------------------- /src/plone/app/theming/tests/resources/othertheme.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Title 4 | 5 | 6 |

Page title

7 |

This is the other theme.

8 | 9 | 10 | -------------------------------------------------------------------------------- /mx.ini: -------------------------------------------------------------------------------- 1 | [settings] 2 | plone = https://github.com/plone 3 | plone_push = git@github.com:plone 4 | 5 | [Products.CMFPlone] 6 | url = ${settings:plone}/Products.CMFPlone.git 7 | pushurl = ${settings:plone_push}/Products.CMFPlone.git 8 | branch = master 9 | -------------------------------------------------------------------------------- /resources/theme/theme1/manifest.cfg: -------------------------------------------------------------------------------- 1 | [theme] 2 | title = Test theme - available in p.a.theming test buildout only! 3 | doctype = 4 | 5 | [theme:parameters] 6 | ajax_load = python: request.form.get('ajax_load') 7 | frobble = string:yes 8 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include * 2 | 3 | recursive-include docs * 4 | recursive-include resources * 5 | recursive-include src * 6 | 7 | global-exclude *pyc 8 | include pyproject.toml 9 | recursive-exclude news * 10 | exclude news 11 | exclude *-mxdev.txt 12 | -------------------------------------------------------------------------------- /src/plone/app/theming/tests/theme.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Title 4 | 5 | 6 |

Page title

7 |

This is the theme.

8 | 9 | 10 | -------------------------------------------------------------------------------- /src/plone/app/theming/themes/template/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Theme template 5 | 6 | 7 | 8 |

Replace this template with your own theme

9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/plone/app/theming/profiles/default/metadata.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 1002 4 | 5 | profile-plone.app.registry:default 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/plone/app/theming/tests/notheme.pt: -------------------------------------------------------------------------------- 1 | 4 | No theme 5 | 6 |

Theme disabled

7 | 8 | 9 | -------------------------------------------------------------------------------- /src/plone/app/theming/tests/resources/theme.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Title 4 | 5 | 6 |

Page title

7 |

This is the theme.

8 | 9 | 10 | -------------------------------------------------------------------------------- /src/plone/app/theming/events.py: -------------------------------------------------------------------------------- 1 | from plone.app.theming.interfaces import IThemeAppliedEvent 2 | from zope.interface import implementer 3 | 4 | 5 | @implementer(IThemeAppliedEvent) 6 | class ThemeAppliedEvent: 7 | def __init__(self, theme): 8 | self.theme = theme 9 | -------------------------------------------------------------------------------- /src/plone/app/theming/tests/another-theme/rules.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /resources/theme/theme1/theme.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Title 4 | 5 | 6 |

Page title

7 |

This is the theme.

8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/plone/app/theming/tests/secondary-theme/rules.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /resources/theme/theme1/views/views.cfg: -------------------------------------------------------------------------------- 1 | [class-view] 2 | class = plone.app.layout.dashboard.dashboard.DashboardView 3 | 4 | [context-view] 5 | for = Products.CMFCore.interfaces.ISiteRoot 6 | 7 | [permission-view] 8 | permission = cmf.ManagePortal 9 | 10 | [name-view] 11 | name = other-name-view 12 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | This package offers a simple way to develop and deploy Plone themes using the Diazo theming engine. 2 | If you are not familiar with Diazo, check out the `Diazo documentation `_. 3 | 4 | It comes with a user guide, reproduced below, available through the theming 5 | control panel. 6 | -------------------------------------------------------------------------------- /src/plone/app/theming/themes.zcml: -------------------------------------------------------------------------------- 1 | 5 | 6 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/plone/app/theming/tests/french.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Title 6 | 7 | 8 |
Actualités
9 | 10 | -------------------------------------------------------------------------------- /src/plone/app/theming/tests/resources/overridestheme.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Title 4 | 5 | 6 |

Page title

7 |

This is the theme.

8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /versions.cfg: -------------------------------------------------------------------------------- 1 | [versions] 2 | # diazo KGS 3 | WebOb = 1.0.8 4 | diazo = 1.0rc3 5 | experimental.cssselect = 0.1 6 | lxml = 2.3 7 | repoze.xmliter = 0.4 8 | 9 | # plone.app.theming KGS, extends diazo KGS 10 | plone.app.theming = 1.0b8 11 | plone.app.themingplugins = 1.0b1 12 | plone.resource = 1.0b5 13 | plone.subrequest = 1.6.1 14 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Generated from: 2 | # https://github.com/plone/meta/tree/main/src/plone/meta/default 3 | # See the inline comments on how to expand/tweak this configuration file 4 | version: 2 5 | updates: 6 | 7 | - package-ecosystem: "github-actions" 8 | directory: "/" 9 | schedule: 10 | # Check for updates to GitHub Actions every week 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /src/plone/app/theming/tests/nonascii.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/plone/app/theming/tests/resources/nonascii.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/plone/app/theming/header.py: -------------------------------------------------------------------------------- 1 | from plone.app.theming.utils import isThemeEnabled 2 | 3 | 4 | def setHeader(object, event): 5 | """Set a header X-Theme-Enabled in the request if theming is enabled. 6 | 7 | This is useful for checking in things like the portal_css/portal_ 8 | javascripts registries. 9 | """ 10 | 11 | request = event.request 12 | 13 | if isThemeEnabled(request): 14 | request.environ["HTTP_X_THEME_ENABLED"] = True 15 | -------------------------------------------------------------------------------- /.meta.toml: -------------------------------------------------------------------------------- 1 | # Generated from: 2 | # https://github.com/plone/meta/tree/main/src/plone/meta/default 3 | # See the inline comments on how to expand/tweak this configuration file 4 | [meta] 5 | template = "default" 6 | commit-id = "2.2.2" 7 | 8 | [pre_commit] 9 | extra_lines = """ 10 | exclude: (resources/.*.pt|tests/.*.pt) 11 | """ 12 | 13 | [tox] 14 | test_matrix = {"6.2" = ["*"]} 15 | use_mxdev = true 16 | test_deps_additional = """ 17 | -esources/Products.CMFPlone 18 | """ 19 | -------------------------------------------------------------------------------- /src/plone/app/theming/exportimport/configure.zcml: -------------------------------------------------------------------------------- 1 | 6 | 7 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/plone/app/theming/tests/localrules.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | 11 | 14 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/plone/app/theming/tests/otherrules.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | 11 | 14 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/plone/app/theming/tests/includes.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | 12 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/plone/app/theming/tests/resources/css-js.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | 12 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/plone/app/theming/plugins/configure.zcml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 13 | 14 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/plone/app/theming/browser/theme-error.pt: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | 12 | 13 |

Invalid rules

14 |

An error occurred trying to parse the rules file:

15 |

16 |   
17 | 
18 | 
19 | 


--------------------------------------------------------------------------------
/src/plone/app/theming/browser/help.py:
--------------------------------------------------------------------------------
 1 | from importlib import resources
 2 | from zope.publisher.browser import BrowserView
 3 | 
 4 | import docutils.core
 5 | 
 6 | 
 7 | class Help(BrowserView):
 8 |     def __call__(self):
 9 |         ref = resources.files("plone.app.theming").joinpath(
10 |             "browser/resources/userguide.rst"
11 |         )
12 |         rstSource = str(ref.read_bytes())
13 | 
14 |         parts = docutils.core.publish_parts(source=rstSource, writer_name="html")
15 |         html = parts["body_pre_docinfo"] + parts["fragment"]
16 |         return f"""
{html:s}
""" 17 | -------------------------------------------------------------------------------- /src/plone/app/theming/upgrade.py: -------------------------------------------------------------------------------- 1 | from Products.CMFCore.utils import getToolByName 2 | 3 | 4 | PROFILE_ID = "profile-plone.app.theming:default" 5 | 6 | 7 | def update_registry(context, logger=None): 8 | # Run the registry.xml step as that may have defined new attributes 9 | setup = getToolByName(context, "portal_setup") 10 | setup.runImportStepFromProfile(PROFILE_ID, "plone.app.registry") 11 | 12 | 13 | def update_controlpanel(context, logger=None): 14 | setup = getToolByName(context, "portal_setup") 15 | setup.runImportStepFromProfile(PROFILE_ID, "controlpanel", run_dependencies=False) 16 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | # Generated from: 2 | # https://github.com/plone/meta/tree/main/src/plone/meta/default 3 | # See the inline comments on how to expand/tweak this configuration file 4 | [flake8] 5 | doctests = 1 6 | ignore = 7 | # black takes care of line length 8 | E501, 9 | # black takes care of where to break lines 10 | W503, 11 | # black takes care of spaces within slicing (list[:]) 12 | E203, 13 | # black takes care of spaces after commas 14 | E231, 15 | 16 | ## 17 | # Add extra configuration options in .meta.toml: 18 | # [flake8] 19 | # extra_lines = """ 20 | # _your own configuration lines_ 21 | # """ 22 | ## 23 | -------------------------------------------------------------------------------- /TODO.txt: -------------------------------------------------------------------------------- 1 | plone.app.theming to-do 2 | ======================= 3 | 4 | General 5 | ------- 6 | 7 | [ ] Improve test coverage 8 | [ ] Create 9 | [ ] Copy 10 | [ ] Delete 11 | [ ] Enable 12 | [ ] Disable 13 | [ ] Advanced save 14 | 15 | Mapper 16 | ------ 17 | 18 | 19 | Future 20 | ------ 21 | 22 | [ ] Let preview show better error if theme not found 23 | [ ] Make preview show current path from content? 24 | [ ] Instead of hiding file manager for read-only themes, show with read-only 25 | editor? 26 | [ ] Working copy editing 27 | [ ] Table-based visual rules list viewer/editor instead of source (unless rules source too complex) 28 | -------------------------------------------------------------------------------- /resources/theme/theme1/overrides/plone.app.layout.viewlets.colophon.pt: -------------------------------------------------------------------------------- 1 |
5 | 6 |
7 | 21 |
22 |
23 | -------------------------------------------------------------------------------- /src/plone/app/theming/tests/another-theme/manifest.cfg: -------------------------------------------------------------------------------- 1 | [theme] 2 | title = Another test theme 3 | description = Another theme for testing 4 | doctype = 5 | prefix = ++theme++another-test-theme 6 | rules = ++theme++another-test-theme/rules.xml 7 | 8 | enabled-bundles = plone 9 | disabled-bundles = foobar 10 | 11 | development-css = ++theme++another-theme/css/barceloneta.css 12 | production-css = ++theme++another-theme/css/barceloneta.min.css 13 | tinymce-content-css = ++theme++another-theme/css/barceloneta.min.css 14 | tinymce-styles-css = ++theme++another-theme/css/custom-format-styles.css 15 | 16 | development-js = ++theme++another-theme/script.js 17 | production-js = ++theme++another-theme/script.min.js 18 | 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | plone.app.testing 2 | Copyright (C) 2010 Plone Foundation 3 | 4 | This program is free software; you can redistribute it and/or 5 | modify it under the terms of the GNU General Public License version 2 6 | as published by the Free Software Foundation. 7 | 8 | This program is distributed in the hope that it will be useful, 9 | but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | GNU General Public License for more details. 12 | 13 | You should have received a copy of the GNU General Public License 14 | along with this program; if not, write to the Free Software 15 | Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 16 | -------------------------------------------------------------------------------- /src/plone/app/theming/tests/secondary-theme/manifest.cfg: -------------------------------------------------------------------------------- 1 | [theme] 2 | title = Secondary test theme 3 | description = Secondary theme for testing 4 | doctype = 5 | prefix = /++theme++secondary-test-theme 6 | rules = /++theme++secondary-test-theme/rules.xml 7 | 8 | enabled-bundles = plone 9 | disabled-bundles = foobar 10 | 11 | development-css = /++theme++secondary-theme/css/barceloneta.css 12 | production-css = /++theme++secondary-theme/css/barceloneta.min.css 13 | tinymce-content-css = /++theme++secondary-theme/css/barceloneta.min.css 14 | tinymce-styles-css = /++theme++secondary-theme/css/custom-format-styles.css 15 | 16 | development-js = /++theme++secondary-theme/script.js 17 | production-js = /++theme++secondary-theme/script.min.js 18 | 19 | -------------------------------------------------------------------------------- /src/plone/app/theming/tests/resources/overridesrules.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 10 | 11 | 14 | 17 | 20 | 21 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/plone/app/theming/tests/resources/rules.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | 12 | 13 | 14 | 17 | 20 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /src/plone/app/theming/tests/rules.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | 12 | 13 | 14 | 17 | 20 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /src/plone/app/theming/browser/themefile.py: -------------------------------------------------------------------------------- 1 | from plone.resource.directory import PersistentResourceDirectory 2 | from Products.Five.browser import BrowserView 3 | 4 | import json 5 | 6 | 7 | class FileUploadView(BrowserView): 8 | """ 9 | Handle file uploads 10 | """ 11 | 12 | def __call__(self): 13 | filedata = self.request.form.get("file", None) 14 | 15 | if filedata is None: 16 | return json.dumps({"failure": "error"}) 17 | 18 | directory = PersistentResourceDirectory(self.context) 19 | name = filedata.filename.encode("utf-8") 20 | data = filedata.read() 21 | 22 | try: 23 | directory.writeFile(name, data) 24 | self.request.response.setHeader("Content-Type", "application/json") 25 | except Exception: 26 | return json.dumps({"failure": "error"}) 27 | 28 | return json.dumps({"success": "create"}) 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated from: 2 | # https://github.com/plone/meta/tree/main/src/plone/meta/default 3 | # See the inline comments on how to expand/tweak this configuration file 4 | # python related 5 | *.egg-info 6 | *.pyc 7 | *.pyo 8 | 9 | # translation related 10 | *.mo 11 | 12 | # tools related 13 | build/ 14 | .coverage 15 | .*project 16 | coverage.xml 17 | dist/ 18 | docs/_build 19 | __pycache__/ 20 | .tox 21 | .vscode/ 22 | node_modules/ 23 | forest.dot 24 | forest.json 25 | 26 | # venv / buildout related 27 | bin/ 28 | develop-eggs/ 29 | eggs/ 30 | .eggs/ 31 | etc/ 32 | .installed.cfg 33 | include/ 34 | lib/ 35 | lib64 36 | .mr.developer.cfg 37 | parts/ 38 | pyvenv.cfg 39 | var/ 40 | local.cfg 41 | 42 | # mxdev 43 | /instance/ 44 | /.make-sentinels/ 45 | /*-mxdev.txt 46 | /reports/ 47 | /sources/ 48 | /venv/ 49 | .installed.txt 50 | 51 | 52 | ## 53 | # Add extra configuration options in .meta.toml: 54 | # [gitignore] 55 | # extra_lines = """ 56 | # _your own configuration lines_ 57 | # """ 58 | ## 59 | -------------------------------------------------------------------------------- /resources/theme/theme1/rules.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | 10 | 13 | 14 | 17 | 21 | 24 | 25 | 28 | 29 | 30 | The value of frobble is 31 | 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /src/plone/app/theming/tests/configure.zcml: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 9 | 13 | 18 | 23 | 24 | 30 | 31 | 37 | 38 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /src/plone/app/theming/traversal.py: -------------------------------------------------------------------------------- 1 | from plone.app.theming.interfaces import THEME_RESOURCE_NAME 2 | from plone.app.theming.utils import theming_policy 3 | from plone.resource.traversal import ResourceTraverser 4 | from plone.resource.utils import queryResourceDirectory 5 | from urllib.parse import quote 6 | from zExceptions import NotFound 7 | 8 | 9 | class ThemeTraverser(ResourceTraverser): 10 | """The theme traverser. 11 | 12 | Allows traversal to /++theme++ using ``plone.resource`` to fetch 13 | things stored either on the filesystem or in the ZODB. 14 | """ 15 | 16 | name = THEME_RESOURCE_NAME 17 | 18 | def __init__(self, context, request=None): 19 | self.context = context 20 | 21 | def current_theme(self): 22 | return theming_policy(self.request).getCurrentTheme() 23 | 24 | def traverse(self, name, remaining): 25 | if name == "": 26 | name = self.current_theme() 27 | 28 | # Note: also fixes possible unicode problems 29 | name = quote(name) 30 | 31 | res = queryResourceDirectory(self.name, name) 32 | if res is not None: 33 | return res 34 | 35 | raise NotFound 36 | -------------------------------------------------------------------------------- /src/plone/app/theming/profiles/default/controlpanel.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 18 | Plone Site Setup: Themes 19 | 20 | 21 | 31 | Plone Site Setup: Themes 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /src/plone/app/theming/browser/custom_css.py: -------------------------------------------------------------------------------- 1 | from plone.app.theming.interfaces import IThemeSettings 2 | from plone.registry.interfaces import IRegistry 3 | from Products.Five.browser import BrowserView 4 | from wsgiref.handlers import format_date_time 5 | from zope.component import getUtility 6 | 7 | import dateutil 8 | import time 9 | 10 | 11 | class CustomCSSView(BrowserView): 12 | """ 13 | Renders custom CSS stored in registry 14 | """ 15 | 16 | def __call__(self): 17 | registry = getUtility(IRegistry) 18 | theme_settings = registry.forInterface(IThemeSettings, False) 19 | self.request.response.setHeader( 20 | "Content-Type", 21 | "text/css; charset=utf-8", 22 | ) 23 | dt = theme_settings.custom_css_timestamp 24 | # If the datetime object is timezone-naive, it is assumed to be local time. 25 | if dt.tzinfo is not None: 26 | dt = dt.astimezone(dateutil.tz.tzlocal()) 27 | # Format a Python datetime object as an RFC1123 date. 28 | self.request.response.setHeader( 29 | "Last-Modified", 30 | format_date_time(time.mktime(dt.timetuple())), 31 | ) 32 | return theme_settings.custom_css 33 | -------------------------------------------------------------------------------- /src/plone/app/theming/tests/paramrules.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 11 | 12 | 15 | 18 | 21 | 22 | 23 | 24 | 25 | 28 | 29 | 30 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /src/plone/app/theming/testing.py: -------------------------------------------------------------------------------- 1 | from plone.app.contenttypes.testing import PLONE_APP_CONTENTTYPES_FIXTURE 2 | from plone.app.testing import applyProfile 3 | from plone.app.testing import PloneSandboxLayer 4 | from plone.app.testing.layers import FunctionalTesting 5 | from plone.app.testing.layers import IntegrationTesting 6 | from zope.configuration import xmlconfig 7 | 8 | 9 | class Theming(PloneSandboxLayer): 10 | defaultBases = (PLONE_APP_CONTENTTYPES_FIXTURE,) 11 | 12 | def setUpZope(self, app, configurationContext): 13 | # load ZCML 14 | import plone.app.theming.tests 15 | 16 | xmlconfig.file( 17 | "configure.zcml", plone.app.theming.tests, context=configurationContext 18 | ) 19 | 20 | # Run the startup hook 21 | from plone.app.theming.plugins.hooks import onStartup 22 | 23 | onStartup(None) 24 | 25 | def setUpPloneSite(self, portal): 26 | # install into the Plone site 27 | applyProfile(portal, "plone.app.theming:default") 28 | 29 | 30 | THEMING_FIXTURE = Theming() 31 | THEMING_INTEGRATION_TESTING = IntegrationTesting( 32 | bases=(THEMING_FIXTURE,), name="Theming:Integration" 33 | ) 34 | THEMING_FUNCTIONAL_TESTING = FunctionalTesting( 35 | bases=(THEMING_FIXTURE,), name="Theming:Functional" 36 | ) 37 | -------------------------------------------------------------------------------- /src/plone/app/theming/zmi.py: -------------------------------------------------------------------------------- 1 | from App.special_dtml import DTMLFile 2 | from zope.globalrequest import getRequest 3 | 4 | import logging 5 | 6 | 7 | LOGGER = logging.getLogger("plone.app.theming") 8 | 9 | 10 | class NoThemeDTMLFile(DTMLFile): 11 | """DTMLFile that automatically sets the X-Theme-Disabled header""" 12 | 13 | def _exec(self, bound_data, args, kw): 14 | request = getRequest() 15 | if request is not None: 16 | request.response.setHeader("X-Theme-Disabled", "1") 17 | return DTMLFile._exec(self, bound_data, args, kw) 18 | 19 | 20 | # Most ZMI pages include 'manage_page_header' 21 | NO_THEME_DTML = [ 22 | "manage", 23 | "manage_page_header", 24 | "manage_top_frame", 25 | ] 26 | 27 | 28 | def disable_theming(func): 29 | def wrapped(self, *args, **kw): 30 | request = getRequest() 31 | if request is not None: 32 | request.response.setHeader("X-Theme-Disabled", "1") 33 | return func(self, *args, **kw) 34 | 35 | return func 36 | 37 | 38 | def patch_zmi(): 39 | from App.Management import Navigation 40 | 41 | for name in NO_THEME_DTML: 42 | dtml = getattr(Navigation, name, None) 43 | if dtml and isinstance(dtml, DTMLFile): 44 | dtml.__class__ = NoThemeDTMLFile 45 | 46 | LOGGER.debug("Patched Zope Management Interface to disable theming.") 47 | -------------------------------------------------------------------------------- /src/plone/app/theming/themes/template/rules.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 16 | 17 | 18 | 21 | 22 | 23 | 26 | 27 | 28 | 32 | 33 | 34 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /src/plone/app/theming/browser/configure.zcml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 12 | 13 | 17 | 18 | 22 | 23 | 30 | 31 | 37 | 38 | 44 | 45 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /src/plone/app/theming/plugins/hooks.py: -------------------------------------------------------------------------------- 1 | from plone.app.theming.interfaces import THEME_RESOURCE_NAME 2 | from plone.app.theming.plugins.utils import getPlugins 3 | from plone.app.theming.plugins.utils import getPluginSettings 4 | from plone.app.theming.utils import theming_policy 5 | from plone.resource.utils import iterDirectoriesOfType 6 | from plone.resource.utils import queryResourceDirectory 7 | 8 | 9 | def onStartup(event): 10 | """Call onDiscovery() on each plugin for each theme on startup""" 11 | plugins = getPlugins() 12 | 13 | for themeDirectory in iterDirectoriesOfType(THEME_RESOURCE_NAME): 14 | pluginSettings = getPluginSettings(themeDirectory, plugins) 15 | 16 | for name, plugin in plugins: 17 | plugin.onDiscovery( 18 | themeDirectory.__name__, pluginSettings[name], pluginSettings 19 | ) 20 | 21 | 22 | def onRequest(object, event): 23 | """Call onRequest() on each plugin for the enabled theme on each request""" 24 | 25 | request = event.request 26 | policy = theming_policy(request) 27 | 28 | if not policy.isThemeEnabled(): 29 | return 30 | 31 | theme = policy.getCurrentTheme() 32 | if theme is None: 33 | return 34 | 35 | themeDirectory = queryResourceDirectory(THEME_RESOURCE_NAME, theme) 36 | if themeDirectory is None: 37 | return 38 | 39 | plugins = getPlugins() 40 | pluginSettings = getPluginSettings(themeDirectory, plugins) 41 | 42 | for name, plugin in plugins: 43 | plugin.onRequest(request, theme, pluginSettings[name], pluginSettings) 44 | -------------------------------------------------------------------------------- /src/plone/app/theming/theme.py: -------------------------------------------------------------------------------- 1 | from plone.app.theming.interfaces import ITheme 2 | from zope.interface import implementer 3 | 4 | 5 | @implementer(ITheme) 6 | class Theme: 7 | """A theme, loaded from a resource directory""" 8 | 9 | def __init__( 10 | self, 11 | name, 12 | rules, 13 | title=None, 14 | description=None, 15 | absolutePrefix=None, 16 | parameterExpressions=None, 17 | doctype=None, 18 | preview=None, 19 | enabled_bundles=[], 20 | disabled_bundles=[], 21 | development_css="", 22 | development_js="", 23 | production_css="", 24 | production_js="", 25 | tinymce_content_css="", 26 | tinymce_styles_css="", 27 | ): 28 | self.__name__ = name 29 | self.rules = rules 30 | self.title = title 31 | self.description = description 32 | self.absolutePrefix = absolutePrefix 33 | self.parameterExpressions = parameterExpressions 34 | self.doctype = doctype 35 | self.preview = preview 36 | self.enabled_bundles = [b for b in enabled_bundles if b] 37 | self.disabled_bundles = [b for b in disabled_bundles if b] 38 | self.tinymce_content_css = tinymce_content_css 39 | self.production_js = production_js 40 | self.production_css = production_css 41 | self.development_js = development_js 42 | self.development_css = development_css 43 | self.tinymce_styles_css = tinymce_styles_css 44 | 45 | def __repr__(self): 46 | return f'' 47 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Generated from: 2 | # https://github.com/plone/meta/tree/main/src/plone/meta/default 3 | # See the inline comments on how to expand/tweak this configuration file 4 | # 5 | # EditorConfig Configuration file, for more details see: 6 | # http://EditorConfig.org 7 | # EditorConfig is a convention description, that could be interpreted 8 | # by multiple editors to enforce common coding conventions for specific 9 | # file types 10 | 11 | # top-most EditorConfig file: 12 | # Will ignore other EditorConfig files in Home directory or upper tree level. 13 | root = true 14 | 15 | 16 | [*] 17 | # Default settings for all files. 18 | # Unix-style newlines with a newline ending every file 19 | end_of_line = lf 20 | insert_final_newline = true 21 | trim_trailing_whitespace = true 22 | # Set default charset 23 | charset = utf-8 24 | # Indent style default 25 | indent_style = space 26 | # Max Line Length - a hard line wrap, should be disabled 27 | max_line_length = off 28 | 29 | [*.{py,cfg,ini}] 30 | # 4 space indentation 31 | indent_size = 4 32 | 33 | [*.{yml,zpt,pt,dtml,zcml,html,xml}] 34 | # 2 space indentation 35 | indent_size = 2 36 | 37 | [*.{json,jsonl,js,jsx,ts,tsx,css,less,scss}] 38 | # Frontend development 39 | # 2 space indentation 40 | indent_size = 2 41 | max_line_length = 80 42 | 43 | [{Makefile,.gitmodules}] 44 | # Tab indentation (no size specified, but view as 4 spaces) 45 | indent_style = tab 46 | indent_size = unset 47 | tab_width = unset 48 | 49 | 50 | ## 51 | # Add extra configuration options in .meta.toml: 52 | # [editorconfig] 53 | # extra_lines = """ 54 | # _your own configuration lines_ 55 | # """ 56 | ## 57 | -------------------------------------------------------------------------------- /.github/workflows/meta.yml: -------------------------------------------------------------------------------- 1 | # Generated from: 2 | # https://github.com/plone/meta/tree/main/src/plone/meta/default 3 | # See the inline comments on how to expand/tweak this configuration file 4 | name: Meta 5 | on: 6 | push: 7 | branches: 8 | - master 9 | - main 10 | pull_request: 11 | branches: 12 | - master 13 | - main 14 | workflow_dispatch: 15 | 16 | ## 17 | # To set environment variables for all jobs, add in .meta.toml: 18 | # [github] 19 | # env = """ 20 | # debug: 1 21 | # image-name: 'org/image' 22 | # image-tag: 'latest' 23 | # """ 24 | ## 25 | 26 | jobs: 27 | qa: 28 | uses: plone/meta/.github/workflows/qa.yml@2.x 29 | coverage: 30 | uses: plone/meta/.github/workflows/coverage.yml@2.x 31 | dependencies: 32 | uses: plone/meta/.github/workflows/dependencies.yml@2.x 33 | release_ready: 34 | uses: plone/meta/.github/workflows/release_ready.yml@2.x 35 | circular: 36 | uses: plone/meta/.github/workflows/circular.yml@2.x 37 | 38 | ## 39 | # To modify the list of default jobs being created add in .meta.toml: 40 | # [github] 41 | # jobs = [ 42 | # "qa", 43 | # "coverage", 44 | # "dependencies", 45 | # "release_ready", 46 | # "circular", 47 | # ] 48 | ## 49 | 50 | ## 51 | # To request that some OS level dependencies get installed 52 | # when running tests/coverage jobs, add in .meta.toml: 53 | # [github] 54 | # os_dependencies = "git libxml2 libxslt" 55 | ## 56 | 57 | 58 | ## 59 | # Specify additional jobs in .meta.toml: 60 | # [github] 61 | # extra_lines = """ 62 | # another: 63 | # uses: org/repo/.github/workflows/file.yml@main 64 | # """ 65 | ## 66 | -------------------------------------------------------------------------------- /src/plone/app/theming/exportimport/handler.py: -------------------------------------------------------------------------------- 1 | from lxml import etree 2 | from plone.app.theming.interfaces import IThemeSettings 3 | from plone.app.theming.utils import applyTheme 4 | from plone.app.theming.utils import getAvailableThemes 5 | from plone.registry.interfaces import IRegistry 6 | from zope.component import getUtility 7 | 8 | 9 | def importTheme(context): 10 | """Apply the theme with the id contained in the profile file theme.xml 11 | and enable the theme. 12 | """ 13 | 14 | data = context.readDataFile("theme.xml") 15 | if not data: 16 | return 17 | 18 | logger = context.getLogger("plone.app.theming.exportimport") 19 | 20 | tree = etree.fromstring(data) 21 | 22 | # apply theme if given and valid 23 | themeName = tree.find("name") 24 | if themeName is not None: 25 | themeName = themeName.text.strip() 26 | themeInfo = None 27 | 28 | allThemes = getAvailableThemes() 29 | for info in allThemes: 30 | if info.__name__.lower() == themeName.lower(): 31 | themeInfo = info 32 | break 33 | 34 | if themeInfo is None: 35 | raise ValueError(f"Theme {themeName:s} is not available") 36 | 37 | applyTheme(themeInfo) 38 | logger.info(f"Theme {themeName:s} applied") 39 | 40 | # enable/disable theme 41 | themeEnabled = tree.find("enabled") 42 | if themeEnabled is None: 43 | return 44 | 45 | settings = getUtility(IRegistry).forInterface(IThemeSettings, False) 46 | 47 | themeEnabled = themeEnabled.text.strip().lower() 48 | if themeEnabled in ( 49 | "y", 50 | "yes", 51 | "true", 52 | "t", 53 | "1", 54 | "on", 55 | ): 56 | settings.enabled = True 57 | logger.info("Theme enabled") 58 | elif themeEnabled in ( 59 | "n", 60 | "no", 61 | "false", 62 | "f", 63 | "0", 64 | "off", 65 | ): 66 | settings.enabled = False 67 | logger.info("Theme disabled") 68 | else: 69 | raise ValueError(f"{themeEnabled:s} is not a valid value for ") 70 | -------------------------------------------------------------------------------- /.github/workflows/test-matrix.yml: -------------------------------------------------------------------------------- 1 | # Generated from: 2 | # https://github.com/plone/meta/tree/main/src/plone/meta/default 3 | # See the inline comments on how to expand/tweak this configuration file 4 | name: Tests 5 | 6 | on: 7 | push: 8 | 9 | jobs: 10 | build: 11 | permissions: 12 | contents: read 13 | pull-requests: write 14 | strategy: 15 | # We want to see all failures: 16 | fail-fast: false 17 | matrix: 18 | os: 19 | - ["ubuntu", "ubuntu-latest"] 20 | config: 21 | # [Python version, visual name, tox env] 22 | - ["3.13", "6.2 on py3.13", "py313-plone62"] 23 | - ["3.10", "6.2 on py3.10", "py310-plone62"] 24 | 25 | runs-on: ${{ matrix.os[1] }} 26 | if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name 27 | name: ${{ matrix.config[1] }} 28 | steps: 29 | - uses: actions/checkout@v6 30 | with: 31 | persist-credentials: false 32 | - name: Set up Python 33 | uses: actions/setup-python@v6 34 | with: 35 | python-version: ${{ matrix.config[0] }} 36 | allow-prereleases: true 37 | 38 | ## 39 | # Add extra configuration options in .meta.toml: 40 | # [github] 41 | # extra_lines_after_os_dependencies = """ 42 | # _your own configuration lines_ 43 | # """ 44 | ## 45 | - name: Pip cache 46 | uses: actions/cache@v5 47 | with: 48 | path: ~/.cache/pip 49 | key: ${{ runner.os }}-pip-${{ matrix.config[0] }}-${{ hashFiles('setup.*', 'tox.ini') }} 50 | restore-keys: | 51 | ${{ runner.os }}-pip-${{ matrix.config[0] }}- 52 | ${{ runner.os }}-pip- 53 | - name: Install dependencies 54 | run: | 55 | python -m pip install --upgrade pip 56 | pip install tox 57 | - name: Initialize tox 58 | # the bash one-liner below does not work on Windows 59 | if: contains(matrix.os, 'ubuntu') 60 | run: | 61 | if [ `tox list --no-desc -f init|wc -l` = 1 ]; then tox -e init;else true; fi 62 | - name: Test 63 | run: tox -e ${{ matrix.config[2] }} 64 | 65 | 66 | ## 67 | # Add extra configuration options in .meta.toml: 68 | # [github] 69 | # extra_lines = """ 70 | # _your own configuration lines_ 71 | # """ 72 | ## 73 | -------------------------------------------------------------------------------- /src/plone/app/theming/browser/resources/controlpanel.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: Roboto, sans-serif; 3 | } 4 | 5 | 6 | body a { 7 | color: #888 8 | } 9 | 10 | body aside, body article{ 11 | padding: 1rem; 12 | } 13 | fieldset { 14 | color: #333; 15 | } 16 | 17 | fieldset .field { 18 | margin-top: 20px; 19 | } 20 | 21 | fieldset label { 22 | font-weight: bold; 23 | text-decoration: underline; 24 | } 25 | 26 | #content-core { 27 | margin-top: 10px; 28 | } 29 | 30 | #themesList { 31 | background-color: #eee; 32 | padding: 10px; 33 | } 34 | 35 | #themesList h3 { 36 | margin-bottom: 10px; 37 | } 38 | 39 | a.btn { 40 | text-decoration: none; 41 | } 42 | 43 | .themeEntry { 44 | display: inline-block; 45 | overflow: hidden; 46 | height: 275px; 47 | width: 400px; 48 | margin: 5px; 49 | padding: 0 6px 0 9px; 50 | text-align: center; 51 | border: solid #eee 1px; 52 | background-color: white; 53 | vertical-align: top; 54 | } 55 | 56 | .activeThemeEntry { 57 | background-color: #ffffe3; 58 | } 59 | 60 | .themeEntry img { 61 | border: 1px solid #ccc; 62 | display: block; 63 | margin: 0 auto; 64 | } 65 | .themeEntryWrapper { 66 | height: 230px; 67 | margin: 0 auto 7px; 68 | } 69 | 70 | .themeEntryDetail { 71 | display: block; 72 | text-decoration: none; 73 | font-size: 90%; 74 | height: 259px; 75 | width: 230px; 76 | margin: 16px auto 0; 77 | } 78 | 79 | .themeEntry .previewImageContainer { 80 | height: 130px; 81 | } 82 | 83 | .themeEntry .previewImageContainer > img { 84 | max-height: 125px; 85 | max-width: 230px; 86 | border: solid 1px #eee; 87 | margin-bottom: 5px; 88 | } 89 | 90 | .themeEntryTitle { 91 | font-size: 20px; 92 | } 93 | 94 | .themeDescription { 95 | color: #76797c; 96 | } 97 | 98 | .themeEntry .themeEntryControls { 99 | clear: both; 100 | margin: 10px; 101 | } 102 | 103 | .themeEntry input, .themeEntry form { 104 | display: inline; 105 | } 106 | 107 | .radioLabel { 108 | font-weight: normal; 109 | } 110 | 111 | .warning { 112 | color: red; 113 | display: block; 114 | font-size: smaller; 115 | } 116 | 117 | .btn-group { 118 | margin: 10px; 119 | } 120 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # Generated from: 2 | # https://github.com/plone/meta/tree/main/src/plone/meta/default 3 | # See the inline comments on how to expand/tweak this configuration file 4 | ci: 5 | autofix_prs: false 6 | autoupdate_schedule: monthly 7 | 8 | repos: 9 | - repo: https://github.com/asottile/pyupgrade 10 | rev: v3.21.2 11 | hooks: 12 | - id: pyupgrade 13 | args: [--py38-plus] 14 | - repo: https://github.com/pycqa/isort 15 | rev: 7.0.0 16 | hooks: 17 | - id: isort 18 | - repo: https://github.com/psf/black-pre-commit-mirror 19 | rev: 25.11.0 20 | hooks: 21 | - id: black 22 | - repo: https://github.com/collective/zpretty 23 | rev: 3.1.1 24 | hooks: 25 | - id: zpretty 26 | 27 | ## 28 | # Add extra configuration options in .meta.toml: 29 | # [pre_commit] 30 | # zpretty_extra_lines = """ 31 | # _your own configuration lines_ 32 | # """ 33 | ## 34 | - repo: https://github.com/PyCQA/flake8 35 | rev: 7.3.0 36 | hooks: 37 | - id: flake8 38 | 39 | ## 40 | # Add extra configuration options in .meta.toml: 41 | # [pre_commit] 42 | # flake8_extra_lines = """ 43 | # _your own configuration lines_ 44 | # """ 45 | ## 46 | - repo: https://github.com/codespell-project/codespell 47 | rev: v2.4.1 48 | hooks: 49 | - id: codespell 50 | additional_dependencies: 51 | - tomli 52 | 53 | ## 54 | # Add extra configuration options in .meta.toml: 55 | # [pre_commit] 56 | # codespell_extra_lines = """ 57 | # _your own configuration lines_ 58 | # """ 59 | ## 60 | - repo: https://github.com/mgedmin/check-manifest 61 | rev: "0.51" 62 | hooks: 63 | - id: check-manifest 64 | - repo: https://github.com/regebro/pyroma 65 | rev: "5.0" 66 | hooks: 67 | - id: pyroma 68 | - repo: https://github.com/mgedmin/check-python-versions 69 | rev: "0.24.0" 70 | hooks: 71 | - id: check-python-versions 72 | args: ['--only', 'setup.py,pyproject.toml'] 73 | - repo: https://github.com/collective/i18ndude 74 | rev: "6.3.0" 75 | hooks: 76 | - id: i18ndude 77 | 78 | 79 | ## 80 | # Add extra configuration options in .meta.toml: 81 | # [pre_commit] 82 | # i18ndude_extra_lines = """ 83 | # _your own configuration lines_ 84 | # """ 85 | ## 86 | 87 | exclude: (resources/.*.pt|tests/.*.pt) 88 | 89 | ## 90 | # Add extra configuration options in .meta.toml: 91 | # [pre_commit] 92 | # extra_lines = """ 93 | # _your own configuration lines_ 94 | # """ 95 | ## 96 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from setuptools import setup 3 | 4 | 5 | version = "7.0.0a3.dev0" 6 | 7 | long_description = ( 8 | f"{Path('README.rst').read_text()}\n" 9 | f"{(Path('src') / 'plone' / 'app' / 'theming' / 'browser' / 'resources' / 'userguide.rst').read_text()}\n" 10 | f"{Path('CHANGES.rst').read_text()}" 11 | ) 12 | 13 | setup( 14 | name="plone.app.theming", 15 | version=version, 16 | description="Integrates the Diazo theming engine with Plone", 17 | long_description=long_description, 18 | long_description_content_type="text/x-rst", 19 | # Get more strings from 20 | # https://pypi.org/classifiers/ 21 | classifiers=[ 22 | "Development Status :: 5 - Production/Stable", 23 | "Framework :: Plone", 24 | "Framework :: Plone :: 6.2", 25 | "Framework :: Plone :: Core", 26 | "License :: OSI Approved :: GNU General Public License (GPL)", 27 | "Programming Language :: Python", 28 | "Programming Language :: Python :: 3.10", 29 | "Programming Language :: Python :: 3.11", 30 | "Programming Language :: Python :: 3.12", 31 | "Programming Language :: Python :: 3.13", 32 | "Topic :: Software Development :: Libraries :: Python Modules", 33 | ], 34 | keywords="plone diazo xdv deliverance theme transform xslt", 35 | author="Martin Aspeli and Laurence Rowe", 36 | author_email="optilude@gmail.com", 37 | url="https://pypi.org/project/plone.app.theming", 38 | license="GPL", 39 | include_package_data=True, 40 | zip_safe=False, 41 | python_requires=">=3.10", 42 | install_requires=[ 43 | "diazo>=1.0.3", 44 | "docutils", 45 | "lxml>=2.2.4", 46 | "plone.app.registry>=1.0", 47 | "plone.base>=4.0.0a1", 48 | "plone.i18n", 49 | "plone.memoize", 50 | "plone.registry", 51 | "plone.resource", 52 | "plone.resourceeditor>=2.0.0", 53 | "plone.staticresources", 54 | "plone.subrequest", 55 | "plone.transformchain", 56 | "python-dateutil", 57 | "Products.GenericSetup", 58 | "Products.statusmessages", 59 | "repoze.xmliter>=0.3", 60 | "Zope", 61 | ], 62 | extras_require={ 63 | "test": [ 64 | "plone.app.testing", 65 | "plone.app.contenttypes[test]", 66 | "plone.testing", 67 | ], 68 | }, 69 | entry_points=""" 70 | [z3c.autoinclude.plugin] 71 | target = plone 72 | """, 73 | ) 74 | -------------------------------------------------------------------------------- /src/plone/app/theming/plugins/utils.py: -------------------------------------------------------------------------------- 1 | from configparser import ConfigParser 2 | from plone.app.theming.interfaces import IThemePlugin 3 | from plone.app.theming.interfaces import THEME_RESOURCE_NAME 4 | from plone.memoize.ram import cache 5 | from plone.resource.manifest import MANIFEST_FILENAME 6 | from zope.component import getUtilitiesFor 7 | 8 | 9 | def pluginsCacheKey(fun): 10 | return len(list(getUtilitiesFor(IThemePlugin))) 11 | 12 | 13 | def pluginSettingsCacheKey(fun, themeDirectory, plugins=None): 14 | return themeDirectory.__name__, len(plugins) 15 | 16 | 17 | def sortDependencies(plugins): 18 | """Topological sort""" 19 | queue = [] 20 | waiting = {} # (n,p) -> [remaining deps] 21 | 22 | for n, p in plugins: 23 | if p.dependencies: 24 | waiting[(n, p)] = list(p.dependencies) 25 | else: 26 | queue.append((n, p)) 27 | 28 | while queue: 29 | n, p = queue.pop() 30 | yield (n, p) 31 | 32 | for (nw, pw), deps in [x for x in waiting.items()]: 33 | if n in deps: 34 | deps.remove(n) 35 | 36 | if not deps: 37 | queue.append((nw, pw)) 38 | del waiting[(nw, pw)] 39 | 40 | if waiting: 41 | raise ValueError(f"Could not resolve dependencies for: {waiting:s}") 42 | 43 | 44 | @cache(pluginsCacheKey) 45 | def getPlugins(): 46 | """Get all registered plugins topologically sorted""" 47 | plugins = [] 48 | 49 | for name, plugin in getUtilitiesFor(IThemePlugin): 50 | plugins.append( 51 | ( 52 | name, 53 | plugin, 54 | ) 55 | ) 56 | 57 | return list(sortDependencies(plugins)) 58 | 59 | 60 | @cache(pluginSettingsCacheKey) 61 | def getPluginSettings(themeDirectory, plugins=None): 62 | """Given an IResourceDirectory for a theme, return the settings for the 63 | given list of plugins (or all plugins, if not given) provided as a list 64 | of (name, plugin) pairs. 65 | 66 | Returns a dict of dicts, with the outer dict having plugin names as keys 67 | and containing plugins settings (key/value pairs) as values. 68 | """ 69 | if plugins is None: 70 | plugins = getPlugins() 71 | 72 | manifestContents = {} 73 | 74 | if themeDirectory.isFile(MANIFEST_FILENAME): 75 | parser = ConfigParser() 76 | fp = themeDirectory.openFile(MANIFEST_FILENAME) 77 | try: 78 | parser.read_string(fp.read().decode()) 79 | for section in parser.sections(): 80 | manifestContents[section] = {} 81 | for name, value in parser.items(section): 82 | manifestContents[section][name] = value 83 | finally: 84 | try: 85 | fp.close() 86 | except AttributeError: 87 | pass 88 | 89 | pluginSettings = {} 90 | for name, plugin in plugins: 91 | pluginSettings[name] = manifestContents.get( 92 | f"{THEME_RESOURCE_NAME:s}:{name:s}", {} 93 | ) 94 | return pluginSettings 95 | -------------------------------------------------------------------------------- /src/plone/app/theming/configure.zcml: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 19 | 20 | 21 | 22 | 23 | 24 | 32 | 33 | 41 | 42 | 49 | 50 | 51 | 55 | 56 | 61 | 62 | 65 | 69 | 71 | 75 | 76 | 77 | 82 | 83 | 84 | 91 | 92 | 93 | 94 | 95 | 96 | -------------------------------------------------------------------------------- /resources/theme/theme1/views/class-view.pt: -------------------------------------------------------------------------------- 1 | 10 | 11 | 12 | 19 | 20 | 21 | 22 | 23 | 24 | Please note that this template fills the "content" slot instead of the 25 | "main" slot, this is done so we can provide stuff like the content 26 | tabs. This also means that we have to supply things that are normally 27 | present from main_template. 28 | 29 | 30 | 31 | 39 | 40 | 41 | 42 |
45 |
Views
46 | 47 | 67 | 68 |
71 |   72 |
73 |
74 | 75 |
76 | Portal status message 77 |
78 |
82 |
Info
83 |
84 | Your dashboard is currently empty. Click the 85 | edit 86 | tab to assign some personal 87 | portlets. 88 |
89 |
90 | 91 |
92 | 93 |

94 | Diazo w0z 3r3 95 |

96 | 97 |
98 |
99 |
100 |
103 |
106 |
109 |
112 |
113 |
114 |
115 |
116 | 117 |
118 |
119 | 120 | 121 | 122 | -------------------------------------------------------------------------------- /src/plone/app/theming/tests/test_controlpanel.py: -------------------------------------------------------------------------------- 1 | from plone.app.testing import setRoles 2 | from plone.app.testing import TEST_USER_ID 3 | from plone.app.testing import TEST_USER_NAME 4 | from plone.app.testing import TEST_USER_PASSWORD 5 | from plone.app.theming.testing import THEMING_FUNCTIONAL_TESTING 6 | from plone.testing.zope import Browser 7 | 8 | import unittest 9 | 10 | 11 | class TestControlPanel(unittest.TestCase): 12 | layer = THEMING_FUNCTIONAL_TESTING 13 | 14 | def setUp(self): 15 | portal = self.layer["portal"] 16 | setRoles(portal, TEST_USER_ID, ["Manager"]) 17 | import transaction 18 | 19 | transaction.commit() 20 | 21 | self.portal = portal 22 | self.browser = Browser(self.layer["app"]) 23 | 24 | handleErrors = self.browser.handleErrors 25 | try: 26 | self.browser.handleErrors = False 27 | self.browser.open(portal.absolute_url() + "/login_form") 28 | self.browser.getControl(name="__ac_name").value = TEST_USER_NAME 29 | self.browser.getControl(name="__ac_password").value = TEST_USER_PASSWORD 30 | self.browser.getControl("Log in").click() 31 | finally: 32 | self.browser.handleErrors = handleErrors 33 | 34 | def goto_controlpanel(self): 35 | self.browser.open(self.portal.absolute_url() + "/@@theming-controlpanel") 36 | 37 | def test_save_advanced(self): 38 | # Simply saving the advanced panel without changes could already give a WrongType error. 39 | # See for example https://github.com/plone/plone.app.theming/issues/179 40 | # but there are more. 41 | self.browser.handleErrors = False 42 | self.goto_controlpanel() 43 | button = self.browser.getControl(name="form.button.AdvancedSave") 44 | button.click() 45 | 46 | def test_create_theme(self): 47 | pass 48 | 49 | # self.goto_controlpanel() 50 | # self.browser.getControl(name='title').value = 'Foobar' 51 | # self.browser.getControl(name='description').value = 'foobar desc' 52 | # self.browser.getControl(name='baseOn').value = ['template'] 53 | # self.browser.getControl( 54 | # name='enableImmediately:boolean:default').value = '' 55 | # self.browser.getControl(name='form.button.CreateTheme').click() 56 | 57 | # self.assertTrue('foobar' in [t.__name__ for t in getZODBThemes()]) 58 | # self.assertTrue(getTheme('foobar') is not None) 59 | 60 | def test_upload_theme_file_nodata(self): 61 | self.browser.addHeader("Accept", "application/json") 62 | self.browser.post( 63 | self.portal.absolute_url() + "/portal_resources/themeFileUpload", 64 | "", 65 | ) 66 | self.assertIn("Status: 200", str(self.browser.headers)) 67 | self.assertIn('{"failure": "error"}', str(self.browser.contents)) 68 | 69 | def test_upload_theme_file_withdata(self): 70 | self.browser.addHeader("Accept", "application/json") 71 | self.browser.post( 72 | self.portal.absolute_url() + "/portal_resources/themeFileUpload", 73 | """ 74 | ---blah--- 75 | Content-Disposition: form-data; name="file"; filename="Screen Shot 2018-02-16 at 3.08.15 pm.png" 76 | Content-Type: image/png 77 | 78 | 79 | ---blah--- 80 | """, 81 | # Bug in testbrowser prevents this working 82 | # content_type='multipart/form-data; boundary=---blah---' 83 | ) 84 | self.assertIn("Status: 200", str(self.browser.headers)) 85 | self.assertIn( 86 | '{"failure": "error"}', # TODO: Should be {'success':'create'} 87 | str(self.browser.contents), 88 | ) 89 | 90 | def test_custom_css(self): 91 | # By default custom.css is empty. 92 | self.browser.handleErrors = False 93 | self.browser.open("custom.css") 94 | self.assertEqual(self.browser.contents, "") 95 | self.assertEqual( 96 | self.browser.headers["Content-Type"], 97 | "text/css; charset=utf-8", 98 | ) 99 | 100 | # Go to the control panel and add custom css. 101 | self.goto_controlpanel() 102 | css = "body {background-color: blue;}" 103 | self.browser.getControl(name="custom_css").value = css 104 | self.browser.getControl(name="form.button.AdvancedSave").click() 105 | 106 | # Check that the css is available. 107 | self.browser.open("custom.css") 108 | self.assertEqual(self.browser.contents, css) 109 | self.assertEqual( 110 | self.browser.headers["Content-Type"], 111 | "text/css; charset=utf-8", 112 | ) 113 | -------------------------------------------------------------------------------- /src/plone/app/theming/tests/test_exportimport.py: -------------------------------------------------------------------------------- 1 | from plone.app.theming.testing import THEMING_INTEGRATION_TESTING 2 | 3 | import unittest 4 | 5 | 6 | class TestExportImport(unittest.TestCase): 7 | layer = THEMING_INTEGRATION_TESTING 8 | 9 | def test_import_filesystem(self): 10 | from plone.app.theming.exportimport.handler import importTheme 11 | from plone.app.theming.interfaces import IThemeSettings 12 | from plone.registry.interfaces import IRegistry 13 | from zope.component import getUtility 14 | 15 | class FauxContext: 16 | def getLogger(self, name): 17 | import logging 18 | 19 | return logging.getLogger(name) 20 | 21 | def readDataFile(self, name): 22 | assert name == "theme.xml" 23 | return "plone.app.theming.tests" 24 | 25 | importTheme(FauxContext()) 26 | 27 | settings = getUtility(IRegistry).forInterface(IThemeSettings, False) 28 | 29 | self.assertEqual(settings.rules, "/++theme++plone.app.theming.tests/rules.xml") 30 | self.assertEqual(settings.absolutePrefix, "/++theme++plone.app.theming.tests") 31 | self.assertEqual( 32 | settings.parameterExpressions, {"foo": "python:request.get('bar')"} 33 | ) 34 | 35 | def test_import_no_file(self): 36 | from plone.app.theming.exportimport.handler import importTheme 37 | from plone.app.theming.interfaces import IThemeSettings 38 | from plone.registry.interfaces import IRegistry 39 | from zope.component import getUtility 40 | 41 | class FauxContext: 42 | def getLogger(self, name): 43 | import logging 44 | 45 | return logging.getLogger(name) 46 | 47 | def readDataFile(self, name): 48 | assert name == "theme.xml" 49 | return None 50 | 51 | settings = getUtility(IRegistry).forInterface(IThemeSettings, False) 52 | rules = settings.rules 53 | absolutePrefix = settings.absolutePrefix 54 | parameterExpressions = settings.parameterExpressions 55 | 56 | importTheme(FauxContext()) 57 | 58 | # should be unchanged 59 | self.assertEqual(settings.rules, rules) 60 | self.assertEqual(settings.absolutePrefix, absolutePrefix) 61 | self.assertEqual(settings.parameterExpressions, parameterExpressions) 62 | 63 | def test_import_not_found(self): 64 | from plone.app.theming.exportimport.handler import importTheme 65 | 66 | class FauxContext: 67 | def getLogger(self, name): 68 | import logging 69 | 70 | return logging.getLogger(name) 71 | 72 | def readDataFile(self, name): 73 | assert name == "theme.xml" 74 | return "invalid-theme-name" 75 | 76 | self.assertRaises(ValueError, importTheme, FauxContext()) 77 | 78 | def test_import_enable(self): 79 | from plone.app.theming.exportimport.handler import importTheme 80 | from plone.app.theming.interfaces import IThemeSettings 81 | from plone.registry.interfaces import IRegistry 82 | from zope.component import getUtility 83 | 84 | class FauxContext: 85 | def getLogger(self, name): 86 | import logging 87 | 88 | return logging.getLogger(name) 89 | 90 | def readDataFile(self, name): 91 | assert name == "theme.xml" 92 | return "true" 93 | 94 | settings = getUtility(IRegistry).forInterface(IThemeSettings, False) 95 | settings.enabled = False 96 | 97 | importTheme(FauxContext()) 98 | 99 | self.assertEqual(settings.enabled, True) 100 | 101 | def test_import_disable(self): 102 | from plone.app.theming.exportimport.handler import importTheme 103 | from plone.app.theming.interfaces import IThemeSettings 104 | from plone.registry.interfaces import IRegistry 105 | from zope.component import getUtility 106 | 107 | class FauxContext: 108 | def getLogger(self, name): 109 | import logging 110 | 111 | return logging.getLogger(name) 112 | 113 | def readDataFile(self, name): 114 | assert name == "theme.xml" 115 | return "false" 116 | 117 | settings = getUtility(IRegistry).forInterface(IThemeSettings, False) 118 | 119 | settings.enabled = True 120 | 121 | importTheme(FauxContext()) 122 | 123 | self.assertEqual(settings.enabled, False) 124 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | # Generated from: 2 | # https://github.com/plone/meta/tree/main/src/plone/meta/default 3 | # See the inline comments on how to expand/tweak this configuration file 4 | [build-system] 5 | requires = ["setuptools>=68.2,<80", "wheel"] 6 | 7 | [tool.towncrier] 8 | directory = "news/" 9 | filename = "CHANGES.rst" 10 | title_format = "{version} ({project_date})" 11 | underlines = ["-", ""] 12 | 13 | [[tool.towncrier.type]] 14 | directory = "breaking" 15 | name = "Breaking changes:" 16 | showcontent = true 17 | 18 | [[tool.towncrier.type]] 19 | directory = "feature" 20 | name = "New features:" 21 | showcontent = true 22 | 23 | [[tool.towncrier.type]] 24 | directory = "bugfix" 25 | name = "Bug fixes:" 26 | showcontent = true 27 | 28 | [[tool.towncrier.type]] 29 | directory = "internal" 30 | name = "Internal:" 31 | showcontent = true 32 | 33 | [[tool.towncrier.type]] 34 | directory = "documentation" 35 | name = "Documentation:" 36 | showcontent = true 37 | 38 | [[tool.towncrier.type]] 39 | directory = "tests" 40 | name = "Tests:" 41 | showcontent = true 42 | 43 | ## 44 | # Add extra configuration options in .meta.toml: 45 | # [pyproject] 46 | # towncrier_extra_lines = """ 47 | # extra_configuration 48 | # """ 49 | ## 50 | 51 | [tool.isort] 52 | profile = "plone" 53 | 54 | ## 55 | # Add extra configuration options in .meta.toml: 56 | # [pyproject] 57 | # isort_extra_lines = """ 58 | # extra_configuration 59 | # """ 60 | ## 61 | 62 | [tool.black] 63 | target-version = ["py38"] 64 | 65 | ## 66 | # Add extra configuration options in .meta.toml: 67 | # [pyproject] 68 | # black_extra_lines = """ 69 | # extra_configuration 70 | # """ 71 | ## 72 | 73 | [tool.codespell] 74 | ignore-words-list = "discreet,assertin,thet," 75 | skip = "*.po," 76 | ## 77 | # Add extra configuration options in .meta.toml: 78 | # [pyproject] 79 | # codespell_ignores = "foo,bar" 80 | # codespell_skip = "*.po,*.map,package-lock.json" 81 | ## 82 | 83 | [tool.dependencychecker] 84 | Zope = [ 85 | # Zope own provided namespaces 86 | 'App', 'OFS', 'Products.Five', 'Products.OFSP', 'Products.PageTemplates', 87 | 'Products.SiteAccess', 'Shared', 'Testing', 'ZPublisher', 'ZTUtils', 88 | 'Zope2', 'webdav', 'zmi', 89 | # ExtensionClass own provided namespaces 90 | 'ExtensionClass', 'ComputedAttribute', 'MethodObject', 91 | # Zope dependencies 92 | 'AccessControl', 'Acquisition', 'AuthEncoding', 'beautifulsoup4', 'BTrees', 93 | 'cffi', 'Chameleon', 'DateTime', 'DocumentTemplate', 94 | 'MultiMapping', 'multipart', 'PasteDeploy', 'Persistence', 'persistent', 95 | 'pycparser', 'python-gettext', 'pytz', 'RestrictedPython', 'roman', 96 | 'soupsieve', 'transaction', 'waitress', 'WebOb', 'WebTest', 'WSGIProxy2', 97 | 'z3c.pt', 'zc.lockfile', 'ZConfig', 'zExceptions', 'ZODB', 'zodbpickle', 98 | 'zope.annotation', 'zope.browser', 'zope.browsermenu', 'zope.browserpage', 99 | 'zope.browserresource', 'zope.cachedescriptors', 'zope.component', 100 | 'zope.configuration', 'zope.container', 'zope.contentprovider', 101 | 'zope.contenttype', 'zope.datetime', 'zope.deferredimport', 102 | 'zope.deprecation', 'zope.dottedname', 'zope.event', 'zope.exceptions', 103 | 'zope.filerepresentation', 'zope.globalrequest', 'zope.hookable', 104 | 'zope.i18n', 'zope.i18nmessageid', 'zope.interface', 'zope.lifecycleevent', 105 | 'zope.location', 'zope.pagetemplate', 'zope.processlifetime', 'zope.proxy', 106 | 'zope.ptresource', 'zope.publisher', 'zope.schema', 'zope.security', 107 | 'zope.sequencesort', 'zope.site', 'zope.size', 'zope.structuredtext', 108 | 'zope.tal', 'zope.tales', 'zope.testbrowser', 'zope.testing', 109 | 'zope.traversing', 'zope.viewlet' 110 | ] 111 | 'Products.CMFCore' = [ 112 | 'docutils', 'five.localsitemanager', 'Missing', 'Products.BTreeFolder2', 113 | 'Products.GenericSetup', 'Products.MailHost', 'Products.PythonScripts', 114 | 'Products.StandardCacheManagers', 'Products.ZCatalog', 'Record', 115 | 'zope.sendmail', 'Zope' 116 | ] 117 | 'plone.base' = [ 118 | 'plone.batching', 'plone.registry', 'plone.schema','plone.z3cform', 119 | 'Products.CMFCore', 'Products.CMFDynamicViewFTI', 120 | ] 121 | python-dateutil = ['dateutil'] 122 | pytest-plone = ['pytest', 'zope.pytestlayer', 'plone.testing', 'plone.app.testing'] 123 | 124 | ## 125 | # Add extra configuration options in .meta.toml: 126 | # [pyproject] 127 | # dependencies_ignores = "['zestreleaser.towncrier']" 128 | # dependencies_mappings = [ 129 | # "gitpython = ['git']", 130 | # "pygithub = ['github']", 131 | # ] 132 | ## 133 | 134 | [tool.check-manifest] 135 | ignore = [ 136 | ".editorconfig", 137 | ".flake8", 138 | ".meta.toml", 139 | ".pre-commit-config.yaml", 140 | "dependabot.yml", 141 | "mx.ini", 142 | "tox.ini", 143 | 144 | ] 145 | 146 | ## 147 | # Add extra configuration options in .meta.toml: 148 | # [pyproject] 149 | # check_manifest_ignores = """ 150 | # "*.map.js", 151 | # "*.pyc", 152 | # """ 153 | # check_manifest_extra_lines = """ 154 | # ignore-bad-ideas = [ 155 | # "some/test/file/PKG-INFO", 156 | # ] 157 | # """ 158 | ## 159 | 160 | 161 | ## 162 | # Add extra configuration options in .meta.toml: 163 | # [pyproject] 164 | # extra_lines = """ 165 | # _your own configuration lines_ 166 | # """ 167 | ## 168 | -------------------------------------------------------------------------------- /src/plone/app/theming/tests/test_policy.py: -------------------------------------------------------------------------------- 1 | from plone.app.theming.testing import THEMING_FUNCTIONAL_TESTING 2 | from plone.app.theming.utils import theming_policy 3 | from plone.registry.interfaces import IRegistry 4 | from zope.component import queryUtility 5 | 6 | import threading 7 | import time 8 | import transaction 9 | import unittest 10 | 11 | 12 | class TestFunctional(unittest.TestCase): 13 | layer = THEMING_FUNCTIONAL_TESTING 14 | 15 | def setUp(self): 16 | request = self.layer["request"] 17 | policy = theming_policy(request) 18 | # avoid cache pollution from other tests 19 | policy.invalidateCache() 20 | 21 | def tearDown(self): 22 | request = self.layer["request"] 23 | policy = theming_policy(request) 24 | # clear local thread caches 25 | policy.invalidateCache() 26 | 27 | def test_getSettings(self): 28 | request = self.layer["request"] 29 | policy = theming_policy(request) 30 | settings = policy.getSettings() 31 | self.assertEqual(settings.currentTheme, "barceloneta") 32 | self.assertEqual(settings.rules, "/++theme++barceloneta/rules.xml") 33 | 34 | def test_getCurrentTheme(self): 35 | request = self.layer["request"] 36 | policy = theming_policy(request) 37 | self.assertEqual(policy.getCurrentTheme(), "barceloneta") 38 | 39 | def test_isThemeEnabled(self): 40 | request = self.layer["request"] 41 | policy = theming_policy(request) 42 | self.assertTrue(policy.isThemeEnabled()) 43 | 44 | def test_isThemeEnabled_blacklist(self): 45 | request = self.layer["request"] 46 | request.set("BASE1", "http://nohost/path/to/site") 47 | policy = theming_policy(request) 48 | settings = policy.getSettings() 49 | # Should pay no attention to BASE1 and only use SERVER_URL 50 | settings.hostnameBlacklist.append("nohost") 51 | self.assertFalse(policy.isThemeEnabled()) 52 | 53 | def test_getCache(self): 54 | request = self.layer["request"] 55 | policy = theming_policy(request) 56 | cache = policy.getCache() 57 | self.assertEqual(cache.themeObj, None) 58 | 59 | def test_getCacheKey(self): 60 | request = self.layer["request"] 61 | policy = theming_policy(request) 62 | self.assertEqual(policy.getCacheKey(), "http://nohost/plone::barceloneta") 63 | 64 | def test_getCacheStorage(self): 65 | request = self.layer["request"] 66 | policy = theming_policy(request) 67 | self.assertEqual(list(policy.getCacheStorage().keys()), ["mtime"]) 68 | cache = policy.getCache() 69 | storage = policy.getCacheStorage() 70 | self.assertEqual( 71 | [(k, v) for (k, v) in storage.items() if k != "mtime"], 72 | [("http://nohost/plone::barceloneta", cache)], 73 | ) 74 | 75 | def test_caching(self): 76 | """roundtrip""" 77 | request = self.layer["request"] 78 | policy = theming_policy(request) 79 | theme = policy.get_theme() 80 | cache = policy.getCache() 81 | storage = policy.getCacheStorage() 82 | self.assertEqual( 83 | [(k, v) for (k, v) in storage.items() if k != "mtime"], 84 | [("http://nohost/plone::barceloneta", cache)], 85 | ) 86 | self.assertEqual(cache.themeObj, theme) 87 | policy.set_theme("barceloneta", "faketheme") 88 | self.assertEqual(policy.get_theme(), "faketheme") 89 | policy.invalidateCache() 90 | self.assertEqual(list(policy.getCacheStorage().keys()), ["mtime"]) 91 | theme2 = policy.get_theme() 92 | # different objects but both are barceloneta 93 | self.assertEqual(theme.title, theme2.title) 94 | 95 | def test_invalidateCache_locally(self): 96 | """Poor man's IPC - verify within same thread""" 97 | request = self.layer["request"] 98 | policy = theming_policy(request) 99 | cache = policy.getCache() 100 | storage = policy.getCacheStorage() 101 | self.assertEqual( 102 | [(k, v) for (k, v) in storage.items() if k != "mtime"], 103 | [("http://nohost/plone::barceloneta", cache)], 104 | ) 105 | shared_mtime_1 = policy._get_shared_invalidation() 106 | policy.invalidateCache() 107 | shared_mtime_2 = policy._get_shared_invalidation() 108 | self.assertTrue(shared_mtime_2 > shared_mtime_1) 109 | 110 | def test_invalidateCache_threaded(self): 111 | """Poor man's IPC - verify in other thread""" 112 | request = self.layer["request"] 113 | policy = theming_policy(request) 114 | cache = policy.getCache() 115 | storage = policy.getCacheStorage() 116 | self.assertEqual( 117 | [(k, v) for (k, v) in storage.items() if k != "mtime"], 118 | [("http://nohost/plone::barceloneta", cache)], 119 | ) 120 | shared_mtime_1 = policy._get_shared_invalidation() 121 | 122 | def invalidate(registry): 123 | setattr(registry, "_theme_cache_mtime", time.time()) 124 | registry._p_modified = True 125 | transaction.commit() 126 | 127 | registry = queryUtility(IRegistry) 128 | t = threading.Thread(target=invalidate, args=(registry,)) 129 | t.start() 130 | t.join(5.0) 131 | 132 | shared_mtime_2 = policy._get_shared_invalidation() 133 | self.assertTrue(shared_mtime_2 > shared_mtime_1) 134 | -------------------------------------------------------------------------------- /src/plone/app/theming/policy.py: -------------------------------------------------------------------------------- 1 | from App.config import getConfiguration 2 | from logging import getLogger 3 | from plone.app.theming import utils 4 | from plone.app.theming.interfaces import IThemeSettings 5 | from plone.app.theming.interfaces import IThemingPolicy 6 | from plone.base.utils import is_truthy 7 | from plone.registry.interfaces import IRegistry 8 | from zope.component import queryUtility 9 | from zope.component.hooks import getSite 10 | from zope.interface import implementer 11 | from zope.publisher.interfaces import IRequest 12 | 13 | import threading 14 | import time 15 | 16 | 17 | logger = getLogger(__name__) 18 | _local_cache = threading.local() 19 | 20 | 21 | def invalidateCache(settings, event): 22 | """Event handler for registry change""" 23 | utils.theming_policy().invalidateCache() 24 | 25 | 26 | @implementer(IThemingPolicy) 27 | class ThemingPolicy: 28 | def __init__(self, request): 29 | """Adapt IRequest. 30 | Do not call this class directly, always use a 31 | utils.theming_policy(request) adapter lookup. 32 | 33 | This enables overriding of the IThemingPolicy adapter 34 | via ZCML by integrators. 35 | 36 | When used as INoRequest adapter, returns the default policy. 37 | """ 38 | if IRequest.providedBy(request): 39 | self.request = request 40 | else: 41 | self.request = None 42 | 43 | def getSettings(self): 44 | """Settings for current theme.""" 45 | registry = queryUtility(IRegistry) 46 | if registry is None: 47 | return None 48 | try: 49 | settings = registry.forInterface(IThemeSettings, False) 50 | except KeyError: 51 | return None 52 | return settings 53 | 54 | def getCurrentTheme(self): 55 | """The name of the current theme.""" 56 | settings = self.getSettings() 57 | if settings.currentTheme: 58 | return settings.currentTheme 59 | 60 | # BBB: If currentTheme isn't set, look for a theme with a rules file 61 | # matching that of the current theme 62 | for theme in utils.getAvailableThemes(): 63 | if theme.rules == settings.rules: 64 | return theme.__name__ 65 | 66 | return None 67 | 68 | def isThemeEnabled(self, settings=None): 69 | """Whether theming is enabled.""" 70 | 71 | # Disable theming if the response sets a header 72 | if self.request.response.getHeader("X-Theme-Disabled"): 73 | return False 74 | 75 | # Resolve debug_mode late (i.e. not on import time) since it may 76 | # be set during import or test setup time 77 | debug_mode = getConfiguration().debug_mode 78 | 79 | # Check for diazo.off request parameter 80 | if debug_mode and is_truthy(self.request.get("diazo.off", False)): 81 | return False 82 | 83 | if not settings: 84 | settings = self.getSettings() 85 | if settings is None or not settings.enabled: 86 | return False 87 | 88 | server_url = self.request.get("SERVER_URL") 89 | proto, host = server_url.split("://", 1) 90 | host = host.lower() 91 | serverPort = self.request.get("SERVER_PORT") 92 | 93 | for hostname in settings.hostnameBlacklist or (): 94 | if host == hostname or host == ":".join((hostname, serverPort)): 95 | return False 96 | 97 | return True 98 | 99 | def getCache(self, theme=None): 100 | """Managing the cache is a policy decision.""" 101 | caches = self.getCacheStorage() 102 | key = self.getCacheKey(theme) 103 | cache = caches.get(key) 104 | if cache is None: 105 | logger.debug( 106 | "initializing local cache on thread %s for %s", 107 | threading.current_thread().ident, 108 | key, 109 | ) 110 | cache = caches[key] = ThemeCache() 111 | return cache 112 | 113 | def getCacheKey(self, theme=None): 114 | if not theme: 115 | theme = self.getCurrentTheme() 116 | key = f"{getSite().absolute_url()}::{theme}" 117 | return key 118 | 119 | def getCacheStorage(self): 120 | if not hasattr(_local_cache, "themedata"): 121 | self._reset_local_cache() 122 | if self._get_shared_invalidation() > _local_cache.themedata["mtime"]: 123 | logger.debug( 124 | "shared invalidation requires local cache reset on %s", 125 | threading.current_thread().ident, 126 | ) 127 | self._reset_local_cache() 128 | return _local_cache.themedata 129 | 130 | def invalidateCache(self): 131 | """When our settings are changed, invalidate the cache on all zeo clients""" 132 | logger.info("invalidating cache across all threads and processes") 133 | self._reset_local_cache() 134 | self._set_shared_invalidation() 135 | 136 | def _reset_local_cache(self): 137 | """ 138 | Invalidate only the local thread cache 139 | Removes actual theme data, leaving only mtime 140 | """ 141 | _local_cache.themedata = {"mtime": time.time()} 142 | logger.debug( 143 | "local cache invalidated on thread %s", threading.current_thread().ident 144 | ) 145 | 146 | def _set_shared_invalidation(self): 147 | """Signal to other threads and processes they should invalidate their 148 | theme caches.""" 149 | registry = queryUtility(IRegistry) 150 | setattr(registry, "_theme_cache_mtime", time.time()) 151 | registry._p_changed = True 152 | logger.debug("shared cache invalidation marker updated") 153 | 154 | def _get_shared_invalidation(self): 155 | registry = queryUtility(IRegistry) 156 | return getattr(registry, "_theme_cache_mtime", 0) 157 | 158 | def get_theme(self): 159 | """Managing the theme cache is a plone.app.theming policy 160 | decision. Moved out out Products.CMFPlone.""" 161 | cache = self.getCache() 162 | themeObj = cache.themeObj 163 | if not themeObj: 164 | theme = self.getCurrentTheme() 165 | themeObj = utils.getTheme(theme) 166 | self.set_theme(theme, themeObj) 167 | return themeObj 168 | 169 | def set_theme(self, themeName, themeObj): 170 | """Update the theme cache""" 171 | cache = self.getCache(themeName) 172 | cache.updateTheme(themeObj) 173 | 174 | 175 | class ThemeCache: 176 | """Simple cache for the transform and theme""" 177 | 178 | def __init__(self): 179 | self.transform = None 180 | self.expressions = None 181 | self.themeObj = None 182 | 183 | def updateTransform(self, transform): 184 | self.transform = transform 185 | 186 | def updateExpressions(self, expressions): 187 | self.expressions = expressions 188 | 189 | def updateTheme(self, themeObj): 190 | self.themeObj = themeObj 191 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # Generated from: 2 | # https://github.com/plone/meta/tree/main/src/plone/meta/default 3 | # See the inline comments on how to expand/tweak this configuration file 4 | [tox] 5 | # We need 4.4.0 for constrain_package_deps. 6 | min_version = 4.4.0 7 | envlist = 8 | lint 9 | test 10 | py313-plone62 11 | py312-plone62 12 | py311-plone62 13 | py310-plone62 14 | dependencies 15 | 16 | 17 | ## 18 | # Add extra configuration options in .meta.toml: 19 | # - to specify a custom testing combination of Plone and python versions, use `test_matrix` 20 | # Use ["*"] to use all supported Python versions for this Plone version. 21 | # - to specify extra custom environments, use `envlist_lines` 22 | # - to specify extra `tox` top-level options, use `config_lines` 23 | # [tox] 24 | # test_matrix = {"6.2" = ["3.13", "3.12"], "6.1" = ["*"]} 25 | # envlist_lines = """ 26 | # my_other_environment 27 | # """ 28 | # config_lines = """ 29 | # my_extra_top_level_tox_configuration_lines 30 | # """ 31 | ## 32 | 33 | [testenv:init] 34 | description = Prepare environment 35 | skip_install = true 36 | allowlist_externals = 37 | echo 38 | deps = 39 | mxdev 40 | commands = 41 | mxdev -c mx.ini 42 | echo "Initial setup for mxdev" 43 | 44 | [testenv:format] 45 | description = automatically reformat code 46 | skip_install = true 47 | deps = 48 | pre-commit 49 | commands = 50 | pre-commit run -a pyupgrade 51 | pre-commit run -a isort 52 | pre-commit run -a black 53 | pre-commit run -a zpretty 54 | 55 | [testenv:lint] 56 | description = run linters that will help improve the code style 57 | skip_install = true 58 | deps = 59 | pre-commit 60 | commands = 61 | pre-commit run -a 62 | 63 | [testenv:dependencies] 64 | description = check if the package defines all its dependencies 65 | skip_install = true 66 | deps = 67 | build 68 | z3c.dependencychecker==2.14.3 69 | commands = 70 | python -m build --sdist 71 | dependencychecker 72 | 73 | [testenv:dependencies-graph] 74 | description = generate a graph out of the dependencies of the package 75 | skip_install = false 76 | allowlist_externals = 77 | sh 78 | deps = 79 | pipdeptree==2.5.1 80 | graphviz # optional dependency of pipdeptree 81 | commands = 82 | sh -c 'pipdeptree --exclude setuptools,wheel,pipdeptree,zope.interface,zope.component --graph-output svg > dependencies.svg' 83 | 84 | 85 | [test_runner] 86 | deps = zope.testrunner 87 | test = 88 | zope-testrunner --all --test-path={toxinidir}/src -s plone.app.theming {posargs} 89 | coverage = 90 | coverage run --branch --source plone.app.theming {envbindir}/zope-testrunner --quiet --all --test-path={toxinidir}/src -s plone.app.theming {posargs} 91 | coverage report -m --format markdown 92 | coverage xml 93 | coverage html 94 | 95 | [base] 96 | description = shared configuration for tests and coverage 97 | use_develop = true 98 | skip_install = false 99 | constrain_package_deps = false 100 | set_env = 101 | ROBOT_BROWSER=headlesschrome 102 | 103 | ## 104 | # Specify extra test environment variables in .meta.toml: 105 | # [tox] 106 | # test_environment_variables = """ 107 | # PIP_EXTRA_INDEX_URL=https://my-pypi.my-server.com/ 108 | # """ 109 | # 110 | # Set constrain_package_deps .meta.toml: 111 | # [tox] 112 | # constrain_package_deps = false 113 | ## 114 | deps = 115 | {[test_runner]deps} 116 | -c constraints-mxdev.txt 117 | -esources/Products.CMFPlone 118 | 119 | ## 120 | # Specify additional deps in .meta.toml: 121 | # [tox] 122 | # test_deps_additional = """ 123 | # -esources/plonegovbr.portal_base[test] 124 | # """ 125 | # 126 | # Specify a custom constraints file in .meta.toml: 127 | # [tox] 128 | # constraints_file = "https://my-server.com/constraints.txt" 129 | ## 130 | extras = 131 | test 132 | 133 | 134 | ## 135 | # Add extra configuration options in .meta.toml: 136 | # [tox] 137 | # test_extras = """ 138 | # tests 139 | # widgets 140 | # """ 141 | # 142 | # Add extra configuration options in .meta.toml: 143 | # [tox] 144 | # testenv_options = """ 145 | # basepython = /usr/bin/python3.8 146 | # """ 147 | ## 148 | 149 | [testenv:test] 150 | description = run the distribution tests 151 | use_develop = {[base]use_develop} 152 | skip_install = {[base]skip_install} 153 | constrain_package_deps = {[base]constrain_package_deps} 154 | set_env = {[base]set_env} 155 | deps = 156 | {[test_runner]deps} 157 | -c constraints-mxdev.txt 158 | -esources/Products.CMFPlone 159 | 160 | commands = {[test_runner]test} 161 | extras = {[base]extras} 162 | 163 | 164 | [testenv] 165 | description = run the distribution tests (generative environments) 166 | use_develop = {[base]use_develop} 167 | skip_install = {[base]skip_install} 168 | constrain_package_deps = {[base]constrain_package_deps} 169 | set_env = {[base]set_env} 170 | deps = {[base]deps} 171 | commands = {[test_runner]test} 172 | extras = {[base]extras} 173 | 174 | 175 | [testenv:coverage] 176 | description = get a test coverage report 177 | use_develop = {[base]use_develop} 178 | skip_install = {[base]skip_install} 179 | constrain_package_deps = {[base]constrain_package_deps} 180 | set_env = {[base]set_env} 181 | deps = 182 | {[test_runner]deps} 183 | coverage 184 | -c constraints-mxdev.txt 185 | -esources/Products.CMFPlone 186 | 187 | commands = {[test_runner]coverage} 188 | extras = {[base]extras} 189 | 190 | 191 | [testenv:release-check] 192 | description = ensure that the distribution is ready to release 193 | skip_install = true 194 | deps = 195 | twine 196 | build 197 | towncrier 198 | -c constraints-mxdev.txt 199 | commands = 200 | # fake version to not have to install the package 201 | # we build the change log as news entries might break 202 | # the README that is displayed on PyPI 203 | towncrier build --version=100.0.0 --yes 204 | python -m build --sdist 205 | twine check dist/* 206 | 207 | [testenv:circular] 208 | description = ensure there are no cyclic dependencies 209 | use_develop = true 210 | skip_install = false 211 | # Here we must always constrain the package deps to what is already installed, 212 | # otherwise we simply get the latest from PyPI, which may not work. 213 | constrain_package_deps = true 214 | set_env = 215 | 216 | ## 217 | # Specify extra test environment variables in .meta.toml: 218 | # [tox] 219 | # test_environment_variables = """ 220 | # PIP_EXTRA_INDEX_URL=https://my-pypi.my-server.com/ 221 | # """ 222 | ## 223 | allowlist_externals = 224 | sh 225 | deps = 226 | pipdeptree 227 | pipforester 228 | -c constraints-mxdev.txt 229 | commands = 230 | # Generate the full dependency tree 231 | sh -c 'pipdeptree -j > forest.json' 232 | # Generate a DOT graph with the circular dependencies, if any 233 | pipforester -i forest.json -o forest.dot --cycles 234 | # Report if there are any circular dependencies, i.e. error if there are any 235 | pipforester -i forest.json --check-cycles -o /dev/null 236 | 237 | 238 | ## 239 | # Add extra configuration options in .meta.toml: 240 | # [tox] 241 | # extra_lines = """ 242 | # _your own configuration lines_ 243 | # """ 244 | ## 245 | -------------------------------------------------------------------------------- /src/plone/app/theming/transform.py: -------------------------------------------------------------------------------- 1 | from App.config import getConfiguration 2 | from lxml import etree 3 | from os import environ 4 | from plone.app.theming import utils 5 | from plone.app.theming.interfaces import IThemingLayer 6 | from plone.app.theming.zmi import patch_zmi 7 | from plone.base.utils import is_truthy 8 | from plone.transformchain.interfaces import ITransform 9 | from repoze.xmliter.utils import getHTMLSerializer 10 | from zope.component import adapter 11 | from zope.interface import implementer 12 | from zope.interface import Interface 13 | from ZPublisher.HTTPRequest import default_encoding 14 | 15 | import logging 16 | 17 | 18 | # Disable theming of ZMI 19 | patch_zmi() 20 | 21 | LOGGER = logging.getLogger("plone.app.theming") 22 | 23 | 24 | @implementer(ITransform) 25 | @adapter(Interface, IThemingLayer) 26 | class ThemeTransform: 27 | """Late stage in the 8000's transform chain. When plone.app.blocks is 28 | used, we can benefit from lxml parsing having taken place already. 29 | """ 30 | 31 | order = 8850 32 | 33 | def __init__(self, published, request): 34 | self.published = published 35 | self.request = request 36 | 37 | def debug_theme(self): 38 | """Check if the theme should be debugged 39 | We will debug the theme 40 | when we have a truish diazo.debug parameter in the request 41 | """ 42 | if not getConfiguration().debug_mode: 43 | return False 44 | return is_truthy(self.request.get("diazo.debug", False)) 45 | 46 | def develop_theme(self): 47 | """Check if the theme should be recompiled 48 | every time the transform is applied 49 | """ 50 | if not getConfiguration().debug_mode: 51 | return False 52 | if self.debug_theme(): 53 | return True 54 | if is_truthy(environ.get("DIAZO_ALWAYS_CACHE_RULES", False)): 55 | return False 56 | return True 57 | 58 | def setupTransform(self, runtrace=False): 59 | debug_mode = self.develop_theme() 60 | policy = utils.theming_policy(self.request) 61 | 62 | # Obtain settings. Do nothing if not found 63 | settings = policy.getSettings() 64 | 65 | if settings is None: 66 | return None 67 | 68 | if not settings.rules: 69 | return None 70 | 71 | if not policy.isThemeEnabled(): 72 | return None 73 | 74 | cache = policy.getCache() 75 | 76 | # Apply theme 77 | transform = None 78 | 79 | if not debug_mode: 80 | transform = cache.transform 81 | 82 | if transform is None: 83 | rules = settings.rules 84 | absolutePrefix = settings.absolutePrefix or None 85 | readNetwork = settings.readNetwork 86 | parameterExpressions = settings.parameterExpressions 87 | 88 | transform = utils.compileThemeTransform( 89 | rules, 90 | absolutePrefix, 91 | readNetwork, 92 | parameterExpressions, 93 | runtrace=runtrace, 94 | ) 95 | if transform is None: 96 | return None 97 | 98 | if not debug_mode: 99 | cache.updateTransform(transform) 100 | 101 | return transform 102 | 103 | def getSettings(self): 104 | return utils.theming_policy(self.request).getSettings() 105 | 106 | def parseTree(self, result): 107 | contentType = self.request.response.getHeader("Content-Type") 108 | if contentType is None or not contentType.startswith("text/html"): 109 | return None 110 | 111 | contentEncoding = self.request.response.getHeader("Content-Encoding") 112 | if contentEncoding and contentEncoding in ( 113 | "zip", 114 | "deflate", 115 | "compress", 116 | ): 117 | return None 118 | 119 | try: 120 | return getHTMLSerializer( 121 | result, pretty_print=False, encoding=default_encoding 122 | ) 123 | except (AttributeError, TypeError, etree.ParseError): 124 | return None 125 | 126 | def transformBytes(self, result, encoding): 127 | try: 128 | result = result.decode(encoding) 129 | except UnicodeDecodeError: 130 | # This is probably a file or an image 131 | # FIXME probably we do not event want to apply 132 | # this transform for files and images 133 | pass 134 | return self.transformIterable([result], encoding) 135 | 136 | def transformString(self, result, encoding): 137 | return self.transformIterable([result], encoding) 138 | 139 | def transformUnicode(self, result, encoding): 140 | return self.transformIterable([result], encoding) 141 | 142 | def transformIterable(self, result, encoding): 143 | """Apply the transform if required""" 144 | # Obtain settings. Do nothing if not found 145 | policy = utils.theming_policy(self.request) 146 | if not policy.isThemeEnabled(): 147 | return None 148 | settings = policy.getSettings() 149 | if settings is None or not settings.rules: 150 | # if there is no rules file given, do not try to transform 151 | return None 152 | result = self.parseTree(result) 153 | if result is None: 154 | return None 155 | 156 | debug_mode = getConfiguration().debug_mode 157 | runtrace = self.debug_theme() 158 | 159 | try: 160 | etree.clear_error_log() 161 | 162 | if settings.doctype: 163 | result.doctype = settings.doctype 164 | if not result.doctype.endswith("\n"): 165 | result.doctype += "\n" 166 | 167 | transform = self.setupTransform(runtrace=runtrace) 168 | if transform is None: 169 | return None 170 | 171 | cache = None 172 | if not debug_mode: 173 | cache = policy.getCache() 174 | 175 | parameterExpressions = settings.parameterExpressions or {} 176 | params = utils.prepareThemeParameters( 177 | utils.findContext(self.request), 178 | self.request, 179 | parameterExpressions, 180 | cache, 181 | ) 182 | 183 | transformed = transform(result.tree, **params) 184 | error_log = transform.error_log 185 | if transformed is not None: 186 | # Transformed worked, swap content with result 187 | result.tree = transformed 188 | except etree.LxmlError as e: 189 | if not (debug_mode): 190 | raise 191 | error_log = e.error_log 192 | runtrace = True 193 | 194 | if runtrace: 195 | from diazo.runtrace import generate_debug_html 196 | 197 | # Add debug information to end of body 198 | body = result.tree.xpath("/html/body")[0] 199 | debug_url = ( 200 | utils.findContext(self.request).portal_url() 201 | + "/++resource++diazo-debug" 202 | ) 203 | body.insert( 204 | -1, 205 | generate_debug_html( 206 | debug_url, 207 | rules=settings.rules, 208 | rules_parser=utils.getParser("rules", settings.readNetwork), 209 | error_log=error_log, 210 | ), 211 | ) 212 | return result 213 | -------------------------------------------------------------------------------- /src/plone/app/theming/interfaces.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from plone.resource.manifest import ManifestFormat 3 | from zope import schema 4 | from zope.i18nmessageid import MessageFactory 5 | from zope.interface import Attribute 6 | from zope.interface import Interface 7 | 8 | 9 | _ = MessageFactory("plone") 10 | 11 | THEME_RESOURCE_NAME = "theme" 12 | RULE_FILENAME = "rules.xml" 13 | DEFAULT_THEME_FILENAME = "index.html" 14 | TEMPLATE_THEME = "template" 15 | 16 | MANIFEST_FORMAT = ManifestFormat( 17 | THEME_RESOURCE_NAME, 18 | keys=[ 19 | "title", 20 | "description", 21 | "rules", 22 | "prefix", 23 | "doctype", 24 | "preview", 25 | "enabled-bundles", 26 | "disabled-bundles", 27 | "development-css", 28 | "production-css", 29 | "tinymce-content-css", 30 | "tinymce-styles-css", 31 | "development-js", 32 | "production-js", 33 | ], 34 | parameterSections=["parameters"], 35 | ) 36 | 37 | THEME_EXTENSIONS = frozenset(["html", "htm"]) 38 | 39 | 40 | def get_default_custom_css_timestamp(): 41 | return datetime.now() 42 | 43 | 44 | class ITheme(Interface): 45 | """A theme, loaded from a resource directory""" 46 | 47 | __name__ = schema.TextLine( 48 | title=_("Name"), 49 | ) 50 | 51 | rules = schema.TextLine( 52 | title=_("Path to rules"), 53 | ) 54 | 55 | title = schema.TextLine( 56 | title=_("Title"), 57 | required=False, 58 | ) 59 | 60 | description = schema.TextLine( 61 | title=_("Description"), 62 | required=False, 63 | ) 64 | 65 | absolutePrefix = schema.TextLine( 66 | title=_("Absolute prefix"), 67 | required=False, 68 | ) 69 | 70 | parameterExpressions = schema.Dict( 71 | title=_("Parameter expressions"), 72 | key_type=schema.TextLine(), 73 | value_type=schema.TextLine(), 74 | required=False, 75 | ) 76 | 77 | doctype = schema.ASCIILine( 78 | title=_("Doctype"), 79 | required=False, 80 | default="", 81 | ) 82 | 83 | preview = schema.ASCIILine( 84 | title=_("Preview image"), 85 | required=False, 86 | ) 87 | 88 | 89 | class IThemeSettings(Interface): 90 | """Transformation settings""" 91 | 92 | enabled = schema.Bool( 93 | title=_("enabled", "Enabled"), 94 | description=_( 95 | "enable_theme_globally", 96 | "Use this option to enable or disable the theme globally. " 97 | "Note that the options will also affect whether the theme " 98 | "is used when this option is enabled.", 99 | ), 100 | required=True, 101 | default=False, 102 | ) 103 | 104 | currentTheme = schema.TextLine( 105 | title=_("current_theme", "Current theme"), 106 | description=_( 107 | "current_theme_description", 108 | "The name of the current theme, i.e. the one applied most " "recently.", 109 | ), 110 | required=True, 111 | ) 112 | 113 | rules = schema.TextLine( 114 | title=_("rules_file", "Rules file"), 115 | description=_("rules_file_path", "File path to the rules file"), 116 | required=False, 117 | ) 118 | 119 | absolutePrefix = schema.TextLine( 120 | title=_("absolute_url_prefix", "Absolute URL prefix"), 121 | description=_( 122 | "convert_relative_url", 123 | "Convert relative URLs in the theme file to absolute paths " 124 | "using this prefix.", 125 | ), 126 | required=False, 127 | ) 128 | 129 | readNetwork = schema.Bool( 130 | title=_("readNetwork", "Read network"), 131 | description=_( 132 | "network_urls_allowed", 133 | "If enabled, network (http, https) urls are allowed in " 134 | "the rules file and this config.", 135 | ), 136 | required=True, 137 | default=False, 138 | ) 139 | 140 | hostnameBlacklist = schema.List( 141 | title=_("hostname_blacklist", "Unthemed host names"), 142 | description=_( 143 | "hostname_blacklist_description", 144 | "If there are hostnames that you do not want to be themed, you " 145 | "can list them here. This is useful during theme development, " 146 | "so that you can compare the themed and unthemed sites. In some " 147 | "cases, you may also want to provided an unthemed host alias for " 148 | "content administrators to be able to use 'plain' Plone.", 149 | ), 150 | value_type=schema.TextLine(), 151 | required=False, 152 | default=["127.0.0.1"], 153 | ) 154 | 155 | parameterExpressions = schema.Dict( 156 | title=_("parameter_expressions", "Parameter expressions"), 157 | description=_( 158 | "parameter_expressions_description", 159 | "You can define parameters here, which will be passed to the " 160 | "compiled theme. In your rules file, you can refer to a " 161 | "parameter by $name. Parameters are defined using TALES " 162 | "expressions, which should evaluate to a string, a number, a " 163 | "boolean or None. Available variables are `context`, `request`, " 164 | "`portal`, `portal_state`, and `context_state`.", 165 | ), 166 | key_type=schema.ASCIILine(), 167 | value_type=schema.ASCIILine(), 168 | required=False, 169 | default={}, 170 | ) 171 | 172 | doctype = schema.ASCIILine( 173 | title=_("doctype", "Doctype"), 174 | description=_( 175 | "doctype_description", 176 | "You can specify a Doctype string which will be set on the " 177 | 'for example "". If left blank the default XHTML ' 178 | "1.0 transitional Doctype or that set in the Diazo theme is used.", 179 | ), 180 | required=False, 181 | default="", 182 | ) 183 | 184 | custom_css = schema.SourceText( 185 | title=_( 186 | "label_custom_css", 187 | "Custom CSS", 188 | ), 189 | description=_( 190 | "help_custom_css", 191 | "Define your own custom CSS in the field below. This is a good " 192 | "place for quick customizations of things like colors and the " 193 | "toolbar. Definitions here will override previously defined CSS " 194 | "of Plone. Please use this only for small customizations, as it " 195 | "is hard to keep track of changes here. For bigger changes you most " 196 | "likely want to customize a full theme and make your changes " 197 | "there.", 198 | ), 199 | default="", 200 | required=False, 201 | ) 202 | 203 | custom_css_timestamp = schema.Datetime( 204 | title=_( 205 | "Custom CSS Timestamp", 206 | ), 207 | description=_( 208 | "Time stamp when the custom CSS was changed. " 209 | "Used to generate custom.css with timestamp in URL.", 210 | ), 211 | defaultFactory=get_default_custom_css_timestamp, 212 | required=False, 213 | ) 214 | 215 | 216 | class IThemingLayer(Interface): 217 | """Browser layer used to indicate that plone.app.theming is installed""" 218 | 219 | 220 | class IThemePlugin(Interface): 221 | """Register a named utility providing this interface to create a theme 222 | plugin. 223 | 224 | The various lifecycle methods will be called with the relevant theme 225 | name and a dictionary called ``settings`` which reflects any settings for 226 | this plugin stored in the theme's manifest. 227 | 228 | Plugin settings are found in a section called ``[theme:pluginname]``. 229 | 230 | Plugins may have dependencies. Dependent plugins are invoked after their 231 | dependencies. The settings of dependencies are passed to lifecycle methods 232 | in the variable ``dependencySetings``, which is a dictionary of 233 | dictionaries. The keys are plugin names, and the values equivalent to 234 | the ``settings`` variable for the corresponding plugin. 235 | 236 | If a given plugin can't be the found, an exception will be thrown during 237 | activation. 238 | """ 239 | 240 | dependencies = schema.Tuple( 241 | title=_("Dependencies"), 242 | description=_("Plugins on which this plugin depends"), 243 | value_type=schema.ASCIILine(), 244 | ) 245 | 246 | def onDiscovery(theme, settings, dependenciesSettings): 247 | """Called when the theme is discovered at startup time. This is 248 | not applicable for through-the-web/zip-file imported themes! 249 | """ 250 | 251 | def onCreated(theme, settings, dependenciesSettings): 252 | """Called when the theme is created through the web (or imported 253 | from a zip file) 254 | """ 255 | 256 | def onEnabled(theme, settings, dependenciesSettings): 257 | """Called when the theme is enabled through the control panel, either 258 | because the global "enabled" flag was switched, or because the theme 259 | was changed. 260 | """ 261 | 262 | def onDisabled(theme, settings, dependenciesSettings): 263 | """Called when the given theme is disabled through the control panel, 264 | either because the global "enabled" flag was switched, or because the 265 | theme was changed. 266 | """ 267 | 268 | def onRequest(request, theme, settings, dependenciesSettings): 269 | """Called upon traversal into the site when a theme is enabled""" 270 | 271 | 272 | class IThemeAppliedEvent(Interface): 273 | theme = Attribute("theme that is getting applied") 274 | 275 | 276 | class INoRequest(Interface): 277 | """Fallback to enable querying for the policy adapter 278 | even in the absence of a proper IRequest.""" 279 | 280 | 281 | class IThemingPolicy(Interface): 282 | """An adapter on request that provides access to the current 283 | theme and theme settings. 284 | """ 285 | 286 | def getSettings(): 287 | """Settings for current theme.""" 288 | 289 | def getCurrentTheme(): 290 | """The name of the current theme.""" 291 | 292 | def isThemeEnabled(): 293 | """Whether theming is enabled.""" 294 | 295 | def getCache(theme=None): 296 | """Managing the cache is a policy decision.""" 297 | 298 | def getCacheKey(theme=None): 299 | """Managing the cache is a policy decision.""" 300 | 301 | def invalidateCache(): 302 | """When our settings are changed, invalidate the cache on all zeo clients.""" 303 | 304 | def get_theme(): 305 | """Returns the current theme object, cached.""" 306 | 307 | def set_theme(themeName, themeObj): 308 | """Update the theme cache.""" 309 | -------------------------------------------------------------------------------- /src/plone/app/theming/browser/controlpanel.py: -------------------------------------------------------------------------------- 1 | from AccessControl import Unauthorized 2 | from datetime import datetime 3 | from plone.app.theming.interfaces import _ 4 | from plone.app.theming.interfaces import DEFAULT_THEME_FILENAME 5 | from plone.app.theming.interfaces import IThemeSettings 6 | from plone.app.theming.interfaces import RULE_FILENAME 7 | from plone.app.theming.interfaces import TEMPLATE_THEME 8 | from plone.app.theming.interfaces import THEME_RESOURCE_NAME 9 | from plone.app.theming.plugins.utils import getPlugins 10 | from plone.app.theming.plugins.utils import getPluginSettings 11 | from plone.app.theming.utils import applyTheme 12 | from plone.app.theming.utils import extractThemeInfo 13 | from plone.app.theming.utils import getAvailableThemes 14 | from plone.app.theming.utils import getOrCreatePersistentResourceDirectory 15 | from plone.app.theming.utils import getZODBThemes 16 | from plone.app.theming.utils import theming_policy 17 | from plone.base.interfaces import IClassicUISchema 18 | from plone.base.interfaces import ILinkSchema 19 | from plone.base.utils import safe_text 20 | from plone.memoize.instance import memoize 21 | from plone.registry.interfaces import IRegistry 22 | from plone.resource.utils import queryResourceDirectory 23 | from Products.CMFCore.utils import getToolByName 24 | from Products.statusmessages.interfaces import IStatusMessage 25 | from zope.component import getMultiAdapter 26 | from zope.component import getUtility 27 | from zope.component.hooks import getSite 28 | from zope.publisher.browser import BrowserView 29 | from zope.schema.interfaces import IVocabularyFactory 30 | 31 | import logging 32 | import zipfile 33 | 34 | 35 | logger = logging.getLogger("plone.app.theming") 36 | 37 | 38 | def authorize(context, request): 39 | authenticator = getMultiAdapter((context, request), name="authenticator") 40 | if not authenticator.verify(): 41 | raise Unauthorized 42 | 43 | 44 | class ThemingControlpanel(BrowserView): 45 | @property 46 | def site_url(self): 47 | """Return the absolute URL to the current site, which is likely not 48 | necessarily the portal root. 49 | """ 50 | return getSite().absolute_url() 51 | 52 | @property 53 | def hostname_blacklist(self): 54 | hostname_blacklist = self.request.get("hostnameBlacklist", []) 55 | return [safe_text(host) for host in hostname_blacklist] 56 | 57 | def __call__(self): 58 | self.pskin = getToolByName(self.context, "portal_skins") 59 | if self.update(): 60 | return self.index() 61 | return "" 62 | 63 | def _setup(self): 64 | registry = getUtility(IRegistry) 65 | self.theme_settings = registry.forInterface(IThemeSettings, False) 66 | self.link_settings = registry.forInterface( 67 | ILinkSchema, prefix="plone", check=False 68 | ) 69 | self.classicui_settings = registry.forInterface( 70 | IClassicUISchema, prefix="plone", check=False 71 | ) 72 | self.zodbThemes = getZODBThemes() 73 | self.availableThemes = getAvailableThemes() 74 | self.selectedTheme = self.getSelectedTheme( 75 | self.availableThemes, 76 | self.theme_settings.currentTheme, 77 | self.theme_settings.rules, 78 | ) 79 | self.overlay = "" 80 | 81 | self.skinsVocabulary = getUtility( 82 | IVocabularyFactory, name="plone.app.vocabularies.Skins" 83 | )(self.context) 84 | 85 | # Set response header to make sure control panel is never themed 86 | self.request.response.setHeader("X-Theme-Disabled", "1") 87 | 88 | def redirect(self, url): 89 | self.request.response.redirect(url) 90 | 91 | def get_mark_special_links(self): 92 | return self.link_settings.mark_special_links 93 | 94 | def set_mark_special_links(self, value): 95 | self.link_settings.mark_special_links = value 96 | 97 | mark_special_links = property(get_mark_special_links, set_mark_special_links) 98 | 99 | def get_ext_links_open_new_window(self): 100 | return self.link_settings.external_links_open_new_window 101 | 102 | def set_ext_links_open_new_window(self, value): 103 | self.link_settings.external_links_open_new_window = value 104 | 105 | ext_links_open_new_window = property( 106 | get_ext_links_open_new_window, set_ext_links_open_new_window 107 | ) 108 | 109 | def get_use_ajax_main_template(self): 110 | return self.classicui_settings.use_ajax_main_template 111 | 112 | def set_use_ajax_main_template(self, value): 113 | self.classicui_settings.use_ajax_main_template = value 114 | 115 | use_ajax_main_template = property( 116 | get_use_ajax_main_template, set_use_ajax_main_template 117 | ) 118 | 119 | def update(self): 120 | # XXX: complexity too high: refactoring needed 121 | self._setup() 122 | self.errors = {} 123 | form = self.request.form 124 | 125 | if "form.button.Cancel" in form: 126 | IStatusMessage(self.request).add(_("Changes cancelled")) 127 | self.redirect(f"{self.site_url}/@@overview-controlpanel") 128 | return False 129 | 130 | if "form.button.Enable" in form: 131 | self.authorize() 132 | 133 | themeSelection = form.get("themeName", None) 134 | 135 | if themeSelection: 136 | themeData = self.getThemeData(self.availableThemes, themeSelection) 137 | applyTheme(themeData) 138 | self.theme_settings.enabled = True 139 | 140 | IStatusMessage(self.request).add( 141 | _( 142 | "Theme enabled. Note that this control panel page is " 143 | "never themed." 144 | ) 145 | ) 146 | self._setup() 147 | return True 148 | 149 | if "form.button.InvalidateCache" in form: 150 | self.authorize() 151 | policy = theming_policy() 152 | policy.invalidateCache() 153 | return True 154 | 155 | if "form.button.Disable" in form: 156 | self.authorize() 157 | 158 | applyTheme(None) 159 | self.theme_settings.enabled = False 160 | 161 | IStatusMessage(self.request).add(_("Theme disabled.")) 162 | self._setup() 163 | return True 164 | 165 | if "form.button.AdvancedSave" in form: 166 | self.authorize() 167 | 168 | self.theme_settings.readNetwork = form.get("readNetwork", False) 169 | 170 | themeEnabled = form.get("themeEnabled", False) 171 | rules = form.get("rules", None) 172 | prefix = form.get("absolutePrefix", None) 173 | doctype = str(form.get("doctype", "")) 174 | 175 | parameterExpressions = {} 176 | parameterExpressionsList = form.get("parameterExpressions", []) 177 | 178 | for line in parameterExpressionsList: 179 | try: 180 | name, expression = line.split("=", 1) 181 | name = str(name.strip()) 182 | expression = str(expression.strip()) 183 | parameterExpressions[name] = expression 184 | except ValueError: 185 | message = _( 186 | "error_invalid_parameter_expressions", 187 | default="Please ensure you enter one expression per " 188 | "line, in the format = .", 189 | ) 190 | self.errors["parameterExpressions"] = message 191 | 192 | themeBase = form.get("themeBase", None) 193 | markSpecialLinks = form.get("markSpecialLinks", None) 194 | extLinksOpenInNewWindow = form.get("extLinksOpenInNewWindow", None) 195 | use_ajax_main_template = form.get("use_ajax_main_template", None) 196 | 197 | custom_css = form.get("custom_css", b"") 198 | 199 | if not self.errors: 200 | # Trigger onDisabled() on plugins if theme was active 201 | # previously and rules were changed 202 | if self.theme_settings.rules != rules: 203 | applyTheme(None) 204 | 205 | self.theme_settings.enabled = themeEnabled 206 | self.theme_settings.rules = rules 207 | self.theme_settings.absolutePrefix = prefix 208 | self.theme_settings.parameterExpressions = parameterExpressions 209 | self.theme_settings.hostnameBlacklist = self.hostname_blacklist 210 | if custom_css != self.theme_settings.custom_css: 211 | self.theme_settings.custom_css_timestamp = datetime.now() 212 | self.theme_settings.custom_css = custom_css 213 | self.theme_settings.doctype = doctype 214 | 215 | # Theme base settings 216 | if themeBase is not None: 217 | self.pskin.default_skin = themeBase 218 | if markSpecialLinks is not None: 219 | self.mark_special_links = markSpecialLinks 220 | if extLinksOpenInNewWindow is not None: 221 | self.ext_links_open_new_window = extLinksOpenInNewWindow 222 | if use_ajax_main_template is not None: 223 | self.use_ajax_main_template = use_ajax_main_template 224 | 225 | IStatusMessage(self.request).add(_("Changes saved")) 226 | self._setup() 227 | return True 228 | else: 229 | IStatusMessage(self.request).add(_("There were errors"), "error") 230 | self.redirectToFieldset("advanced") 231 | return False 232 | 233 | if "form.button.Import" in form: 234 | self.authorize() 235 | 236 | enableNewTheme = form.get("enableNewTheme", False) 237 | replaceExisting = form.get("replaceExisting", False) 238 | themeArchive = form.get("themeArchive", None) 239 | 240 | themeZip = None 241 | performImport = False 242 | 243 | try: 244 | themeZip = zipfile.ZipFile(themeArchive) 245 | except (zipfile.BadZipfile, zipfile.LargeZipFile): 246 | logger.exception("Could not read zip file") 247 | self.errors["themeArchive"] = _( 248 | "error_invalid_zip", 249 | default="The uploaded file is not a valid Zip archive", 250 | ) 251 | 252 | if themeZip: 253 | try: 254 | themeData = extractThemeInfo(themeZip, checkRules=False) 255 | except (ValueError, KeyError) as e: 256 | logger.warn(str(e)) 257 | self.errors["themeArchive"] = _( 258 | "error_no_rules_file", 259 | "The uploaded file does not contain a valid theme " "archive.", 260 | ) 261 | else: 262 | themeContainer = getOrCreatePersistentResourceDirectory() 263 | themeExists = themeData.__name__ in themeContainer 264 | 265 | if themeExists: 266 | if not replaceExisting: 267 | self.errors["themeArchive"] = _( 268 | "error_already_installed", 269 | "This theme is already installed. Select " 270 | "'Replace existing theme' and re-upload to " 271 | "replace it.", 272 | ) 273 | else: 274 | del themeContainer[themeData.__name__] 275 | performImport = True 276 | else: 277 | performImport = True 278 | 279 | if performImport: 280 | themeContainer.importZip(themeZip) 281 | 282 | themeDirectory = queryResourceDirectory( 283 | THEME_RESOURCE_NAME, themeData.__name__ 284 | ) 285 | if themeDirectory is not None: 286 | # If we don't have a rules file, use the template 287 | if themeData.rules == "/++{:s}++{:s}/{:s}".format( 288 | THEME_RESOURCE_NAME, 289 | themeData.__name__, 290 | RULE_FILENAME, 291 | ) and not themeDirectory.isFile(RULE_FILENAME): 292 | templateThemeDirectory = queryResourceDirectory( 293 | THEME_RESOURCE_NAME, TEMPLATE_THEME 294 | ) 295 | themeDirectory.writeFile( 296 | RULE_FILENAME, 297 | templateThemeDirectory.readFile(RULE_FILENAME), 298 | ) 299 | 300 | if not themeDirectory.isFile(DEFAULT_THEME_FILENAME): 301 | IStatusMessage(self.request).add( 302 | _( 303 | "A boilerplate rules.xml was added to " 304 | "your theme, but no index.html file " 305 | "found. Update rules.xml to reference " 306 | "the current theme file." 307 | ), 308 | "warning", 309 | ) 310 | 311 | plugins = getPlugins() 312 | pluginSettings = getPluginSettings(themeDirectory, plugins) 313 | if pluginSettings is not None: 314 | for name, plugin in plugins: 315 | plugin.onCreated( 316 | themeData.__name__, pluginSettings[name], pluginSettings 317 | ) 318 | 319 | if enableNewTheme: 320 | applyTheme(themeData) 321 | self.theme_settings.enabled = True 322 | 323 | if not self.errors: 324 | self.redirect( 325 | "{}/@@theming-controlpanel".format( 326 | self.site_url, 327 | ) 328 | ) 329 | return False 330 | else: 331 | IStatusMessage(self.request).add(_("There were errors"), "error") 332 | 333 | self.renderOverlay("upload") 334 | return True 335 | 336 | if "form.button.DeleteSelected" in form: 337 | self.authorize() 338 | 339 | toDelete = form.get("themes", []) 340 | themeDirectory = getOrCreatePersistentResourceDirectory() 341 | 342 | for theme in toDelete: 343 | del themeDirectory[theme] 344 | 345 | IStatusMessage(self.request).add(_("Theme deleted"), "info") 346 | 347 | self._setup() 348 | return True 349 | 350 | return True 351 | 352 | def getSelectedTheme(self, themes, themeName, rules): 353 | for item in themes: 354 | if item.__name__ == themeName: 355 | return item.__name__ 356 | 357 | # BBB: If currentTheme isn't set, look for a theme with a rules file 358 | # matching that of the current theme. Same as what policy does 359 | for item in themes: 360 | if item.rules == rules: 361 | return item.__name__ 362 | return None 363 | 364 | def getThemeData(self, themes, themeSelection): 365 | for item in themes: 366 | if item.__name__ == themeSelection: 367 | return item 368 | return None 369 | 370 | @memoize 371 | def themeList(self): 372 | themes = [] 373 | zodbNames = [t.__name__ for t in self.zodbThemes] 374 | 375 | complete = [] 376 | active_theme = None 377 | 378 | for theme in self.availableThemes: 379 | if theme.__name__ == TEMPLATE_THEME: 380 | continue 381 | 382 | # We've overwritten this theme, skip it 383 | if complete.__contains__(theme.__name__): 384 | continue 385 | 386 | override = False 387 | 388 | # Is there more than one theme with the same name? 389 | if ( 390 | len([x for x in self.availableThemes if x.__name__ == theme.__name__]) 391 | > 1 392 | ): 393 | # Then we make sure we're using the TTW version, not the filesystem version. 394 | try: 395 | theme = list( 396 | filter(lambda x: x.__name__ == theme.__name__, self.zodbThemes) 397 | )[0] 398 | override = True 399 | # Or when TTW is not available, the first available filesystem version. 400 | except IndexError: 401 | theme = list( 402 | filter( 403 | lambda x: x.__name__ == theme.__name__, self.availableThemes 404 | ) 405 | )[0] 406 | 407 | previewUrl = "++resource++plone.app.theming/defaultPreview.png" 408 | if theme.preview: 409 | previewUrl = "++theme++{:s}/{:s}".format( 410 | theme.__name__, 411 | theme.preview, 412 | ) 413 | 414 | theme_data = { 415 | "name": theme.__name__, 416 | "title": theme.title, 417 | "description": theme.description, 418 | "override": override, 419 | "editable": theme.__name__ in zodbNames, 420 | "preview": f"{self.site_url}/{previewUrl}", 421 | "selected": theme.__name__ == self.selectedTheme, 422 | } 423 | if theme.__name__ == self.selectedTheme: 424 | active_theme = theme_data 425 | else: 426 | themes.append(theme_data) 427 | 428 | complete.append(theme.__name__) 429 | 430 | themes.sort(key=lambda x: x["title"]) 431 | if active_theme: 432 | themes.insert(0, active_theme) 433 | 434 | return themes 435 | 436 | def redirectToFieldset(self, fieldset): 437 | self.redirect(f"{self.site_url}/{self.__name__}#fieldsetlegend-{fieldset}") 438 | 439 | def renderOverlay(self, overlay): 440 | self.overlay = overlay 441 | 442 | def authorize(self): 443 | return authorize(self.context, self.request) 444 | -------------------------------------------------------------------------------- /src/plone/app/theming/utils.py: -------------------------------------------------------------------------------- 1 | from Acquisition import aq_base 2 | from configparser import ConfigParser 3 | from diazo.compiler import compile_theme 4 | from diazo.compiler import quote_param 5 | from importlib import resources 6 | from io import BytesIO 7 | from io import StringIO 8 | from lxml import etree 9 | from plone.app.theming.interfaces import INoRequest 10 | from plone.app.theming.interfaces import IThemingPolicy 11 | from plone.app.theming.interfaces import MANIFEST_FORMAT 12 | from plone.app.theming.interfaces import RULE_FILENAME 13 | from plone.app.theming.interfaces import THEME_RESOURCE_NAME 14 | from plone.app.theming.plugins.utils import getPlugins 15 | from plone.app.theming.plugins.utils import getPluginSettings 16 | from plone.app.theming.theme import Theme 17 | from plone.base.utils import safe_bytes 18 | from plone.base.utils import safe_text 19 | from plone.i18n.normalizer.interfaces import IURLNormalizer 20 | from plone.resource.interfaces import IResourceDirectory 21 | from plone.resource.manifest import extractManifestFromZipFile 22 | from plone.resource.manifest import getAllResources 23 | from plone.resource.manifest import getManifest 24 | from plone.resource.manifest import getZODBResources 25 | from plone.resource.manifest import MANIFEST_FILENAME 26 | from plone.resource.utils import cloneResourceDirectory 27 | from plone.resource.utils import iterDirectoriesOfType 28 | from plone.resource.utils import queryResourceDirectory 29 | from plone.subrequest import subrequest 30 | from Products.CMFCore.interfaces import IContentish 31 | from Products.CMFCore.interfaces import ISiteRoot 32 | from Products.PageTemplates.Expressions import getEngine 33 | from urllib.parse import urlsplit 34 | from zope.component import getUtility 35 | from zope.component import queryMultiAdapter 36 | from zope.globalrequest import getRequest 37 | from zope.interface import implementer 38 | 39 | import logging 40 | import os 41 | 42 | 43 | LOGGER = logging.getLogger("plone.app.theming") 44 | 45 | 46 | @implementer(INoRequest) 47 | class NoRequest: 48 | """Fallback to enable querying for the policy adapter 49 | even in the absence of a proper IRequest.""" 50 | 51 | 52 | def theming_policy(request=None): 53 | """Primary policy accessor, uses pluggable ZCA lookup. 54 | Resolves into a IThemingPolicy adapter.""" 55 | if not request: 56 | request = getRequest() 57 | if not request: 58 | request = NoRequest() # the adapter knows how to handle this 59 | return IThemingPolicy(request) 60 | 61 | 62 | class FailingFileProtocolResolver(etree.Resolver): 63 | """Resolver that fails for security when file: urls are tried. 64 | 65 | Note: an earlier version only checked for "file://", not "file:", 66 | and did not catch relative paths. 67 | """ 68 | 69 | def resolve(self, system_url, public_id, context): 70 | if system_url.startswith("file:") and system_url != "file:///__diazo__": 71 | # The error will be caught by lxml and we only see this in the traceback: 72 | # XIncludeError: could not load , and no fallback was found 73 | raise ValueError("File protocol access not allowed: '%s'" % system_url) 74 | 75 | 76 | class FailingFileSystemResolver(etree.Resolver): 77 | """Resolver that fails for security when accessing the file system. 78 | 79 | Problem 1: none of the current plone.app.theming resolvers 80 | resolve file system paths, and yet they get resolved. 81 | So somewhere in etree there is a fallback. 82 | 83 | Problem 2: the InternalResolver of plone.app.theming can resolve paths 84 | internal in the Plone Site. If that happens, then our failing resolver 85 | should not be called. But the order in which resolvers are called, 86 | seems random, so we cannot rely on the InternalResolver being called first. 87 | 88 | So what do we do? 89 | 90 | Situation: 91 | - The Plone Site has a theme.html in the site root. 92 | - On the file system there is a file theme.html in the root. 93 | 94 | Possibilities when resolving /theme.html: 95 | 96 | A. The InternalResolver is called first, and resolves it correctly. 97 | B. Our FailingFileSystemResolver is called first, 98 | sees that the file exists, and raises an error. 99 | 100 | In this situation, the resolving would randomly work and not work. 101 | This seems unavoidable, but also seems a corner case 102 | which will not happen very often. 103 | 104 | In case the file does not exist on the file system, 105 | our resolver should return nothing. 106 | Then the InternalResolver or other resolvers can have a go. 107 | """ 108 | 109 | def resolve(self, system_url, public_id, context): 110 | if system_url and os.path.exists(system_url): 111 | # The error will be caught by lxml and we only see this in the traceback: 112 | # XIncludeError: could not load , and no fallback was found 113 | raise ValueError("File system access not allowed: '%s'" % system_url) 114 | 115 | 116 | class NetworkResolver(etree.Resolver): 117 | """Resolver for network urls""" 118 | 119 | def resolve(self, system_url, public_id, context): 120 | if "://" in system_url and system_url != "file:///__diazo__": 121 | return self.resolve_filename(system_url, context) 122 | 123 | 124 | class PythonResolver(etree.Resolver): 125 | """Resolver for python:// paths""" 126 | 127 | def resolve(self, system_url, public_id, context): 128 | if not system_url.lower().startswith("python://"): 129 | return None 130 | filename = resolvePythonURL(system_url) 131 | return self.resolve_filename(filename, context) 132 | 133 | 134 | def resolvePythonURL(url): 135 | """Resolve the python resource url to it's path 136 | This can resolve python://dotted.package.name/file/path URLs to paths. 137 | """ 138 | assert url.lower().startswith("python://") 139 | spec = url[9:] 140 | package, resource_name = spec.split("/", 1) 141 | ref = resources.files(package) / resource_name 142 | return str(ref) 143 | 144 | 145 | class InternalResolver(etree.Resolver): 146 | """Resolver for internal absolute and relative paths (excluding protocol). 147 | If the path starts with a /, it will be resolved relative to the Plone 148 | site navigation root. 149 | """ 150 | 151 | def resolve(self, system_url, public_id, context): 152 | request = getRequest() 153 | if request is None: 154 | return None 155 | 156 | # Ignore URLs with a scheme 157 | if "://" in system_url: 158 | return None 159 | 160 | # Ignore the special 'diazo:' resolvers 161 | if system_url.startswith("diazo:"): 162 | return None 163 | 164 | context = findContext(request) 165 | portalState = queryMultiAdapter((context, request), name="plone_portal_state") 166 | root = portalState.navigation_root() 167 | 168 | is_absolute_url = system_url.startswith("/") 169 | if not is_absolute_url: 170 | root_path = root.getPhysicalPath() 171 | context_path = context.getPhysicalPath()[len(root_path) :] 172 | if len(context_path) == 0: 173 | system_url = "/" + system_url 174 | else: 175 | system_url = "/{:s}/{:s}".format("/".join(context_path), system_url) 176 | 177 | response = subrequest(system_url, root=root) 178 | if is_absolute_url and response.status == 401: 179 | # If we tried on the navigation root we can retry on the portal: 180 | # the navigation root may be private. This is especially needed 181 | # when requesting theme resources: otherwise accessing a public 182 | # page within a private navigation root would show unstyled. 183 | # See https://github.com/plone/plone.app.theming/issues/142 184 | portal = portalState.portal() 185 | if aq_base(portal) is not aq_base(root): 186 | response = subrequest(system_url, root=portal) 187 | if response.status != 200: 188 | LOGGER.error(f"Couldn't resolve {system_url:s}") 189 | return None 190 | result = response.getBody() 191 | content_type = response.headers.get("content-type") 192 | encoding = None 193 | if content_type is not None and ";" in content_type: 194 | content_type, encoding = content_type.split(";", 1) 195 | if encoding is None: 196 | encoding = "utf-8" 197 | else: 198 | # e.g. charset=utf-8 199 | encoding = encoding.split("=", 1)[1].strip() 200 | result = result.decode(encoding) 201 | if content_type == "text/html": 202 | # Note: at first the xmlcharrefreplace was only done on Python 2, 203 | # but Python 3 needs it as well, but only for html. 204 | # See https://github.com/plone/Products.CMFPlone/issues/3068 205 | result = result.encode("ascii", "xmlcharrefreplace") 206 | 207 | if content_type in ("text/javascript", "application/x-javascript"): 208 | result = "".join( 209 | [ 210 | '", 213 | ] 214 | ) 215 | elif content_type == "text/css": 216 | result = "".join( 217 | [ 218 | '", 221 | ] 222 | ) 223 | 224 | return self.resolve_string(result, context) 225 | 226 | 227 | def getPortal(): 228 | """Return the portal object""" 229 | request = getRequest() 230 | context = findContext(request) 231 | portalState = queryMultiAdapter((context, request), name="plone_portal_state") 232 | if portalState is None: 233 | return None 234 | return portalState.portal() 235 | 236 | 237 | def findContext(request): 238 | """Find the context from the request""" 239 | published = request.get("PUBLISHED", None) 240 | context = getattr(published, "__parent__", None) 241 | if context is not None: 242 | return context 243 | 244 | for parent in request.PARENTS: 245 | if IContentish.providedBy(parent) or ISiteRoot.providedBy(parent): 246 | return parent 247 | 248 | return request.PARENTS[0] 249 | 250 | 251 | def findPathContext(path): 252 | """Find context given by physical path""" 253 | portal = getPortal() 254 | 255 | if path in (None, "", "/"): 256 | return portal 257 | 258 | seq = path.strip("/").split("/") 259 | while seq: 260 | try: 261 | obj = portal.restrictedTraverse("/".join(seq)) 262 | except Exception: 263 | seq.pop() 264 | else: 265 | if IContentish.providedBy(obj): 266 | return obj 267 | else: 268 | seq.pop() 269 | 270 | 271 | def expandAbsolutePrefix(prefix): 272 | """Prepend the Plone site URL to the prefix if it starts with /""" 273 | if not prefix or not prefix.startswith("/"): 274 | return prefix 275 | portal = getPortal() 276 | if portal is None: 277 | return "" 278 | path = portal.absolute_url_path() 279 | if path and path.endswith("/"): 280 | path = path[:-1] 281 | return path + prefix 282 | 283 | 284 | def getOrCreatePersistentResourceDirectory(): 285 | """Obtain the 'theme' persistent resource directory, creating it if 286 | necessary. 287 | """ 288 | 289 | persistentDirectory = getUtility(IResourceDirectory, name="persistent") 290 | if THEME_RESOURCE_NAME not in persistentDirectory: 291 | persistentDirectory.makeDirectory(THEME_RESOURCE_NAME) 292 | 293 | return persistentDirectory[THEME_RESOURCE_NAME] 294 | 295 | 296 | def createExpressionContext(context, request): 297 | """Create an expression context suitable for evaluating parameter 298 | expressions. 299 | """ 300 | 301 | contextState = queryMultiAdapter((context, request), name="plone_context_state") 302 | portalState = queryMultiAdapter((context, request), name="plone_portal_state") 303 | 304 | data = { 305 | "context": context, 306 | "request": request, 307 | "portal": portalState.portal(), 308 | "context_state": contextState, 309 | "portal_state": portalState, 310 | "nothing": None, 311 | } 312 | 313 | return getEngine().getContext(data) 314 | 315 | 316 | def compileExpression(text): 317 | """Compile the given expression. The returned value is suitable for 318 | caching in a volatile attribute 319 | """ 320 | return getEngine().compile(text.strip()) 321 | 322 | 323 | def isValidThemeDirectory(directory): 324 | """Determine if the given plone.resource directory is a valid theme 325 | directory 326 | """ 327 | return directory.isFile(MANIFEST_FILENAME) or directory.isFile(RULE_FILENAME) 328 | 329 | 330 | def extractThemeInfo(zipfile, checkRules=True): 331 | """Return an ITheme based on the information in the given zipfile. 332 | Will throw a ValueError if the theme directory does not contain a single 333 | top level directory or the rules file cannot be found. 334 | Set checkRules=False to disable the rules check. 335 | """ 336 | 337 | name, manifest = extractManifestFromZipFile(zipfile, MANIFEST_FORMAT) 338 | if not manifest: 339 | manifest = {} 340 | rules = manifest.get("rules", None) 341 | if rules is None: 342 | if checkRules: 343 | try: 344 | zipfile.getinfo(f"{name:s}/{RULE_FILENAME:s}") 345 | except KeyError: 346 | raise ValueError("Could not find theme name and rules file") 347 | return getTheme(name, manifest) 348 | 349 | 350 | def getTheme(name, manifest=None, resources=None): 351 | if manifest is None: 352 | if resources is None: 353 | resources = getAllResources(MANIFEST_FORMAT, filter=isValidThemeDirectory) 354 | if name not in resources: 355 | return None 356 | manifest = resources[name] or {} 357 | 358 | title = manifest.get("title", None) 359 | if title is None: 360 | title = name.capitalize().replace("-", " ").replace(".", " ") 361 | description = manifest.get("description", None) 362 | rules = manifest.get("rules", None) 363 | if rules is None: 364 | rules = "/++{:s}++{:s}/{:s}".format( 365 | THEME_RESOURCE_NAME, 366 | name, 367 | RULE_FILENAME, 368 | ) 369 | prefix = manifest.get("prefix", None) 370 | if prefix is None: 371 | prefix = f"/++{THEME_RESOURCE_NAME:s}++{name:s}" 372 | params = manifest.get("parameters", None) or {} 373 | doctype = manifest.get("doctype", None) or "" 374 | preview = manifest.get("preview", None) 375 | enabled_bundles = manifest.get("enabled-bundles", None) or "" 376 | enabled_bundles = enabled_bundles.split(",") if enabled_bundles else [] 377 | disabled_bundles = manifest.get("disabled-bundles", None) or "" 378 | disabled_bundles = disabled_bundles.split(",") if disabled_bundles else [] 379 | development_css = manifest.get("development-css", None) or "" 380 | development_js = manifest.get("development-js", None) or "" 381 | production_css = manifest.get("production-css", None) or "" 382 | production_js = manifest.get("production-js", None) or "" 383 | tinymce_content_css = manifest.get("tinymce-content-css", None) or "" 384 | tinymce_styles_css = manifest.get("tinymce-styles-css", None) or "" 385 | if isinstance(rules, bytes): 386 | rules = rules.decode("utf-8") 387 | if isinstance(prefix, bytes): 388 | prefix = prefix.decode("utf-8") 389 | return Theme( 390 | name, 391 | rules, 392 | title=title, 393 | description=description, 394 | absolutePrefix=prefix, 395 | parameterExpressions=params, 396 | doctype=doctype, 397 | preview=preview, 398 | enabled_bundles=enabled_bundles, 399 | disabled_bundles=disabled_bundles, 400 | development_css=development_css, 401 | development_js=development_js, 402 | production_css=production_css, 403 | production_js=production_js, 404 | tinymce_content_css=tinymce_content_css, 405 | tinymce_styles_css=tinymce_styles_css, 406 | ) 407 | 408 | 409 | def getAvailableThemes(): 410 | """Get a list of all ITheme's available in resource directories.""" 411 | resources = getThemeResources(MANIFEST_FORMAT, filter=isValidThemeDirectory) 412 | themes = [] 413 | for theme in resources: 414 | themes.append(getTheme(theme["name"], theme)) 415 | 416 | themes.sort(key=lambda x: safe_text(x.title)) 417 | return themes 418 | 419 | 420 | def getThemeResources( 421 | format, defaults=None, filter=None, manifestFilename=MANIFEST_FILENAME 422 | ): 423 | resources = [] 424 | 425 | for directory in iterDirectoriesOfType( 426 | format.resourceType, filter_duplicates=False 427 | ): 428 | if filter is not None and not filter(directory): 429 | continue 430 | 431 | name = directory.__name__ 432 | 433 | if directory.isFile(manifestFilename): 434 | manifest = directory.openFile(manifestFilename) 435 | try: 436 | theme = getManifest(manifest, format, defaults) 437 | theme["name"] = name 438 | resources.append(theme) 439 | except Exception: 440 | LOGGER.exception("Unable to read manifest for theme directory %s", name) 441 | finally: 442 | manifest.close() 443 | 444 | return resources 445 | 446 | 447 | def getThemeFromResourceDirectory(resourceDirectory): 448 | """Return a Theme object from a resource directory""" 449 | name = resourceDirectory.__name__ 450 | if resourceDirectory.isFile(MANIFEST_FILENAME): 451 | with resourceDirectory.openFile(MANIFEST_FILENAME) as manifest_fp: 452 | manifest = getManifest(manifest_fp, MANIFEST_FORMAT) 453 | else: 454 | manifest = {} 455 | 456 | return getTheme(name, manifest) 457 | 458 | 459 | def getZODBThemes(): 460 | """Get a list of ITheme's stored in the ZODB.""" 461 | 462 | resources = getZODBResources(MANIFEST_FORMAT, filter=isValidThemeDirectory) 463 | themes = [] 464 | for name, manifest in resources.items(): 465 | themes.append(getTheme(name, manifest)) 466 | 467 | themes.sort(key=lambda x: x.title) 468 | return themes 469 | 470 | 471 | def getCurrentTheme(): 472 | """Get the name of the currently enabled theme""" 473 | return theming_policy().getCurrentTheme() 474 | 475 | 476 | def isThemeEnabled(request, settings=None): 477 | """Determine if a theme is enabled for the given request""" 478 | return theming_policy(request).isThemeEnabled(settings) 479 | 480 | 481 | def applyTheme(theme): 482 | """Apply an ITheme""" 483 | # on write, force using default policy 484 | policy = IThemingPolicy(NoRequest()) 485 | settings = policy.getSettings() 486 | 487 | plugins = None 488 | themeDirectory = None 489 | pluginSettings = None 490 | currentTheme = policy.getCurrentTheme() 491 | 492 | if currentTheme is not None: 493 | themeDirectory = queryResourceDirectory(THEME_RESOURCE_NAME, currentTheme) 494 | if themeDirectory is not None: 495 | plugins = getPlugins() 496 | pluginSettings = getPluginSettings(themeDirectory, plugins) 497 | 498 | if theme is None: 499 | settings.currentTheme = None 500 | settings.rules = None 501 | settings.absolutePrefix = None 502 | settings.parameterExpressions = {} 503 | settings.doctype = "" 504 | 505 | if pluginSettings is not None: 506 | for name, plugin in plugins: 507 | plugin.onDisabled(currentTheme, pluginSettings[name], pluginSettings) 508 | 509 | else: 510 | if not isinstance(theme.rules, str): 511 | theme.rules = theme.rules.decode("utf-8") 512 | 513 | if not isinstance(theme.absolutePrefix, str): 514 | theme.absolutePrefix = theme.absolutePrefix.decode("utf-8") 515 | 516 | if not isinstance(theme.__name__, str): 517 | theme.__name__ = theme.__name__.decode("utf-8") 518 | 519 | settings.currentTheme = theme.__name__ 520 | settings.rules = theme.rules 521 | settings.absolutePrefix = theme.absolutePrefix 522 | settings.parameterExpressions = theme.parameterExpressions 523 | settings.doctype = theme.doctype 524 | 525 | if pluginSettings is not None: 526 | for name, plugin in plugins: 527 | plugin.onDisabled(currentTheme, pluginSettings[name], pluginSettings) 528 | 529 | currentTheme = settings.currentTheme 530 | themeDirectory = queryResourceDirectory(THEME_RESOURCE_NAME, currentTheme) 531 | if themeDirectory is not None: 532 | plugins = getPlugins() 533 | pluginSettings = getPluginSettings(themeDirectory, plugins) 534 | 535 | if pluginSettings is not None: 536 | for name, plugin in plugins: 537 | plugin.onEnabled(currentTheme, pluginSettings[name], pluginSettings) 538 | policy.set_theme(currentTheme, theme) 539 | 540 | 541 | def createThemeFromTemplate(title, description, baseOn="template"): 542 | """Create a new theme from the given title and description based on 543 | another theme resource directory 544 | """ 545 | 546 | source = queryResourceDirectory(THEME_RESOURCE_NAME, baseOn) 547 | if source is None: 548 | raise KeyError(f"Theme {baseOn:s} not found") 549 | 550 | themeName = getUtility(IURLNormalizer).normalize(title) 551 | resources = getOrCreatePersistentResourceDirectory() 552 | 553 | resources.makeDirectory(themeName) 554 | target = resources[themeName] 555 | 556 | cloneResourceDirectory(source, target) 557 | 558 | manifest = ConfigParser() 559 | 560 | if MANIFEST_FILENAME in target: 561 | # configparser can only read/write text 562 | # but in py3 plone.resource objects are BytesIO objects. 563 | fp = target.openFile(MANIFEST_FILENAME) 564 | try: 565 | data = fp.read() 566 | finally: 567 | fp.close() 568 | manifest.read_string(safe_text(data)) 569 | 570 | if not manifest.has_section("theme"): 571 | manifest.add_section("theme") 572 | 573 | manifest.set("theme", "title", title) 574 | manifest.set("theme", "description", description) 575 | 576 | if manifest.has_option("theme", "prefix"): 577 | prefix = f"/++{THEME_RESOURCE_NAME}++{themeName}" 578 | manifest.set("theme", "prefix", prefix) 579 | 580 | if manifest.has_option("theme", "rules"): 581 | rule = manifest.get("theme", "rules") 582 | rule_file_name = rule.split("/")[-1] # extract real rules file name 583 | rules = f"/++{THEME_RESOURCE_NAME}++{themeName}/{rule_file_name}" 584 | manifest.set("theme", "rules", rules) 585 | 586 | paths_to_fix = [ 587 | "development-css", 588 | "production-css", 589 | "tinymce-content-css", 590 | "tinymce-styles-css", 591 | "development-js", 592 | "production-js", 593 | ] 594 | for var_path in paths_to_fix: 595 | if not manifest.has_option("theme", var_path): 596 | continue 597 | val = manifest.get("theme", var_path) 598 | if not val: 599 | continue 600 | template_prefix = f"++{THEME_RESOURCE_NAME}++{baseOn}/" 601 | if template_prefix in val: 602 | # okay, fix 603 | val = val.replace(template_prefix, f"++{THEME_RESOURCE_NAME}++{themeName}/") 604 | manifest.set("theme", var_path, val) 605 | 606 | # plone.resource uses OFS.File which is a BytesIO objects 607 | # but configparser can only deal with text (StringIO). 608 | # So we need to do this stupid dance to write manifest.cfg 609 | tempfile = StringIO() 610 | manifest.write(tempfile) 611 | tempfile.seek(0) 612 | data = tempfile.read() 613 | tempfile.close() 614 | manifestContents = BytesIO(safe_bytes(data)) 615 | 616 | target.writeFile(MANIFEST_FILENAME, manifestContents) 617 | return themeName 618 | 619 | 620 | def getParser(type, readNetwork): 621 | """Set up a parser for either rules, theme or compiler""" 622 | 623 | if type == "rules": 624 | parser = etree.XMLParser(recover=False, resolve_entities=False, remove_pis=True) 625 | elif type == "theme": 626 | parser = etree.HTMLParser() 627 | elif type == "compiler": 628 | parser = etree.XMLParser(resolve_entities=False, remove_pis=True) 629 | # Note: the order in which resolvers are called, seems random. 630 | # They end up in a set. 631 | parser.resolvers.add(InternalResolver()) 632 | parser.resolvers.add(PythonResolver()) 633 | if readNetwork: 634 | parser.resolvers.add(NetworkResolver()) 635 | parser.resolvers.add(FailingFileProtocolResolver()) 636 | parser.resolvers.add(FailingFileSystemResolver()) 637 | return parser 638 | 639 | 640 | def compileThemeTransform( 641 | rules, 642 | absolutePrefix=None, 643 | readNetwork=False, 644 | parameterExpressions=None, 645 | runtrace=False, 646 | ): 647 | """Prepare the theme transform by compiling the rules with the given options""" 648 | 649 | if parameterExpressions is None: 650 | parameterExpressions = {} 651 | 652 | accessControl = etree.XSLTAccessControl( 653 | read_file=True, 654 | write_file=False, 655 | create_dir=False, 656 | read_network=readNetwork, 657 | write_network=False, 658 | ) 659 | 660 | if absolutePrefix: 661 | absolutePrefix = expandAbsolutePrefix(absolutePrefix) 662 | params = {"url", "base", "path", "scheme", "host"} 663 | params.update(parameterExpressions.keys()) 664 | xslParams = {k: "" for k in params} 665 | 666 | compiledTheme = compile_theme( 667 | rules, 668 | absolute_prefix=absolutePrefix, 669 | parser=getParser("theme", readNetwork), 670 | rules_parser=getParser("rules", readNetwork), 671 | compiler_parser=getParser("compiler", readNetwork), 672 | read_network=readNetwork, 673 | access_control=accessControl, 674 | update=True, 675 | xsl_params=xslParams, 676 | runtrace=runtrace, 677 | ) 678 | 679 | if not compiledTheme: 680 | return None 681 | 682 | return etree.XSLT( 683 | compiledTheme, 684 | access_control=accessControl, 685 | ) 686 | 687 | 688 | def prepareThemeParameters(context, request, parameterExpressions, cache=None): 689 | """Prepare and return a dict of parameter expression values.""" 690 | 691 | # Find real or virtual path - PATH_INFO has VHM elements in it 692 | url = request.get("ACTUAL_URL", "") 693 | 694 | # Find the host name 695 | base = request.get("BASE1", "") 696 | path = url[len(base) :] 697 | parts = urlsplit(base.lower()) 698 | 699 | params = dict( 700 | url=quote_param(url), 701 | base=quote_param(base), 702 | path=quote_param(path), 703 | scheme=quote_param(parts.scheme), 704 | host=quote_param(parts.netloc), 705 | ) 706 | 707 | # Add expression-based parameters 708 | if not parameterExpressions: 709 | return params 710 | 711 | # Compile and cache expressions 712 | expressions = None 713 | if cache is not None: 714 | expressions = cache.expressions 715 | 716 | if expressions is None: 717 | expressions = {} 718 | for name, expressionText in parameterExpressions.items(): 719 | expressions[name] = compileExpression(expressionText) 720 | 721 | if cache is not None: 722 | cache.updateExpressions(expressions) 723 | 724 | # Execute all expressions 725 | expressionContext = createExpressionContext(context, request) 726 | for name, expression in expressions.items(): 727 | params[name] = quote_param(expression(expressionContext)) 728 | 729 | return params 730 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | 5 | .. You should *NOT* be adding new change log entries to this file. 6 | You should create a file in the news directory instead. 7 | For helpful instructions, please see: 8 | https://github.com/plone/plone.releaser/blob/master/ADD-A-NEWS-ITEM.rst 9 | 10 | .. towncrier release notes start 11 | 12 | 7.0.0a2 (2025-11-26) 13 | -------------------- 14 | 15 | Breaking changes: 16 | 17 | 18 | - Replace ``pkg_resources`` namespace with PEP 420 native namespace. 19 | Support only Plone 6.2 and Python 3.10+. (#3928) 20 | 21 | 22 | 7.0.0a1 (2025-11-19) 23 | -------------------- 24 | 25 | Breaking changes: 26 | 27 | 28 | - Drop compatibility with Plone 6.1. 29 | 30 | The new IClassicUISchema.use_ajax_main_template setting needs latest plone.base 31 | 4.0.0a1 which is part of CMFPlone 6.2. 32 | [thet] 33 | 34 | 35 | New features: 36 | 37 | 38 | - Expose the ``IClassicUISchema`` registry setting ``use_ajax_main_template`` in the theming control panel. 39 | 40 | 41 | 6.0.0 (2025-09-05) 42 | ------------------ 43 | 44 | Breaking changes: 45 | 46 | 47 | - Require `plone.base` 3.1.0 or higher, as we use its new `is_truthy` function. 48 | This drops compatibility with Plone 6.0. 49 | Releases 5.0.13 and 5.0.14 already use this new function, but don't specify the minimum requirement. 50 | Release 6.0.0 is the same as 5.0.14, but with the new minimum requirement. 51 | Meanwhile 5.0.15 has been made from branch 5.x instead of master, and this uses its own version of the `is_truthy` function, instead of requiring the one from `plone.base`. 52 | See `issue 263 `_. 53 | [maurits] (#263) 54 | 55 | 56 | 5.0.14 (2025-09-05) 57 | ------------------- 58 | 59 | Internal: 60 | 61 | 62 | - Update configuration files. 63 | [plone devs] 64 | 65 | 66 | 5.0.13 (2025-06-18) 67 | ------------------- 68 | 69 | Bug fixes: 70 | 71 | 72 | - Allow to disable rule caching not only by removing but also by setting "DIAZO_ALWAYS_CACHE_RULES" to a value which evaluates to False. 73 | [thet] (#245) 74 | 75 | 76 | Internal: 77 | 78 | 79 | - Use is_truthy from plone.base for yes/no alike parameters. 80 | [thet] (#257) 81 | 82 | 83 | 5.0.12 (2025-03-11) 84 | ------------------- 85 | 86 | Bug fixes: 87 | 88 | 89 | - Replace `pkg_resources` with `importlib.resources` @gforcada (#4126) 90 | 91 | 92 | 5.0.11 (2024-12-16) 93 | ------------------- 94 | 95 | Bug fixes: 96 | 97 | 98 | - Update the link and label under Site Setup > Theming > Advanced settings > Custom Styles to Theming of Classic UI at https://6.docs.plone.org/classic-ui/theming/index.html. @stevepiercy (#248) 99 | 100 | 101 | 5.0.10 (2024-09-05) 102 | ------------------- 103 | 104 | Internal: 105 | 106 | 107 | - Minor optimization to disable Diazo theming via `X-Theme-Disabled` a tick earlier. 108 | [thet] (#244) 109 | 110 | 111 | 5.0.9 (2024-05-06) 112 | ------------------ 113 | 114 | Bug fixes: 115 | 116 | 117 | - Fix an issue with unicode characters happening with lxml 5 [ale-rt] (#238) 118 | 119 | 120 | 5.0.8 (2024-04-22) 121 | ------------------ 122 | 123 | Bug fixes: 124 | 125 | 126 | - Traverse to theme resources from the navigation root again. 127 | Only when this gives an Unauthorized, try it on the portal as a fall back. 128 | This fixes other use cases of traversing to absolute urls in a theme. 129 | [maurits] (#236) 130 | 131 | 132 | 5.0.7 (2024-01-18) 133 | ------------------ 134 | 135 | Bug fixes: 136 | 137 | 138 | - Traverse to theme resources from the portal. Fix broken theme when rendering accessible content contained in an inaccessible navigation-root. Fixes #142 139 | [pbauer] (#142) 140 | 141 | 142 | 5.0.6 (2023-12-14) 143 | ------------------ 144 | 145 | Bug fixes: 146 | 147 | 148 | - Fix AttributeError in ``custom.css``: "module 'wsgiref' has no attribute 'handlers'". 149 | [maurits] (#230) 150 | 151 | 152 | 5.0.5 (2023-10-07) 153 | ------------------ 154 | 155 | Internal: 156 | 157 | 158 | - Update configuration files. 159 | [plone devs] (cfffba8c) 160 | 161 | 162 | 5.0.4 (2023-06-22) 163 | ------------------ 164 | 165 | Bug fixes: 166 | 167 | 168 | - Unify default values for translations 169 | [erral] (#223) 170 | 171 | 172 | 5.0.3 (2023-04-06) 173 | ------------------ 174 | 175 | Bug fixes: 176 | 177 | 178 | - Fix theme for `pat-code-editor`. 179 | [petschki] (#219) 180 | 181 | 182 | 5.0.2 (2023-03-22) 183 | ------------------ 184 | 185 | Bug fixes: 186 | 187 | 188 | - Fixes circular dependency on ZCML level to `Products.CMFPlone`: 189 | Move permission id=`plone.app.controlpanel.Themes` title=`Plone Site Setup: Themes` to this package. 190 | [jensens] (permission-move) 191 | 192 | 193 | Internal: 194 | 195 | 196 | - Update configuration files. 197 | [plone devs] (80cf330f) 198 | 199 | 200 | 5.0.1 (2023-03-14) 201 | ------------------ 202 | 203 | Bug fixes: 204 | 205 | 206 | - Import more from plone.base. 207 | Removed compatibility code for Python 2 and Zope 4. 208 | [maurits] (#1) 209 | - Bugfix: If there is no rules.xml given in a theme, abort transform early. 210 | Otherwise, given an open file is given as iterable, the read buffer pointer is empty after this function, but None returned. 211 | The new way the read buffer pointer is not touched. 212 | [jensens, toalba] (#216) 213 | 214 | 215 | 5.0.0 (2022-12-02) 216 | ------------------ 217 | 218 | Bug fixes: 219 | 220 | 221 | - Final release for Plone 6.0.0. (#600) 222 | 223 | 224 | 5.0.0b2 (2022-10-11) 225 | -------------------- 226 | 227 | Bug fixes: 228 | 229 | 230 | - Fix for `tinymce-styles-css` when copying theme. 231 | [petschki] (#214) 232 | 233 | 234 | 5.0.0b1 (2022-08-31) 235 | -------------------- 236 | 237 | Bug fixes: 238 | 239 | 240 | - The action buttons in the theming control panel have been improved 241 | [rohnsha0] (#212) 242 | 243 | 244 | 5.0.0a5 (2022-04-04) 245 | -------------------- 246 | 247 | New features: 248 | 249 | 250 | - Deactivate copy button and modal in theming control panel. [MrTango] (#205) 251 | - Load barceloneta css in theming control panel to have it styled. [MrTango] (#205) 252 | - Remove all thememapper functionality from theming control panel, 253 | including Inspect/Modify theme and the Preview. [maurits] (#205) 254 | - Use pat-code-editor for custom-css field. [MrTango] (#205) 255 | 256 | 257 | 5.0.0a4 (2021-11-23) 258 | -------------------- 259 | 260 | Bug fixes: 261 | 262 | 263 | - Add missing i18n:translate tags 264 | [erral] (#204) 265 | 266 | 267 | 5.0.0a3 (2021-10-13) 268 | -------------------- 269 | 270 | Bug fixes: 271 | 272 | 273 | - Use HTML5 meta charset. 274 | [thet] (#203) 275 | 276 | 277 | 5.0.0a2 (2021-09-15) 278 | -------------------- 279 | 280 | Bug fixes: 281 | 282 | 283 | - Fix unclosed file when reading manifest.cfg 284 | [petschki] (#199) 285 | - Remove cyclic dependency with Products.CMFPlone 286 | [sneridagh] (#201) 287 | 288 | 289 | 5.0.0a1 (2021-07-26) 290 | -------------------- 291 | 292 | Breaking changes: 293 | 294 | 295 | - Add bootstrap icon from resolver from Plone 6. 296 | [petschki, agitator] (#194) 297 | 298 | 299 | Bug fixes: 300 | 301 | 302 | - Avoid Server Side Request Forgery via lxml parser. 303 | Taken over from `PloneHotfix20210518 `_. 304 | [maurits] (#3274) 305 | 306 | 307 | 4.1.6 (2020-11-17) 308 | ------------------ 309 | 310 | Bug fixes: 311 | 312 | 313 | - For increased security, fail when trying file protocol access in diazo rules. 314 | Also do not resolve entities, and remove processing instructions. 315 | [maurits] (#3209) 316 | 317 | 318 | 4.1.5 (2020-09-26) 319 | ------------------ 320 | 321 | Bug fixes: 322 | 323 | 324 | - Fixed WrongContainedType for hostnameBlackList on Zope 5. 325 | See also `issue 183 `_. 326 | [maurits] (#183) 327 | - Fixed deprecation warning for ConfigParser.readfp. 328 | [maurits] (#3130) 329 | 330 | 331 | 4.1.4 (2020-08-14) 332 | ------------------ 333 | 334 | Bug fixes: 335 | 336 | 337 | - Fix a missing import [ale-rt] (#188) 338 | 339 | 340 | 4.1.3 (2020-07-30) 341 | ------------------ 342 | 343 | Bug fixes: 344 | 345 | 346 | - Fixes #187: Invalid dependency on plone.app.caching 347 | [jensens] (#187) 348 | - Cleanup: Remove meanwhile unused test fixture code referring to ``plone.app.caching``. 349 | Removed class and fixtures: ``ThemingWithCaching``, ``THEMINGWITHCACHING_FIXTURE``, ``THEMINGWITHCACHING_TESTING``. 350 | Those were nowhere used active in Plone nor outside in Github. 351 | [jensens] (#188) 352 | 353 | 354 | 4.1.2 (2020-07-01) 355 | ------------------ 356 | 357 | Bug fixes: 358 | 359 | 360 | - Internationalize the Custom CSS placeholder. 361 | This fixes https://github.com/plone/Products.CMFPlone/issues/3139 362 | [vincentfretin] (#186) 363 | 364 | 365 | 4.1.1 (2020-06-24) 366 | ------------------ 367 | 368 | Bug fixes: 369 | 370 | 371 | - Fix i18n of new messages related to new Custom CSS feature. 372 | [vincentfretin] (#185) 373 | 374 | 375 | 4.1.0 (2020-06-16) 376 | ------------------ 377 | 378 | New features: 379 | 380 | 381 | - Insert diazo bundle without rules. 382 | [santonelli] (#176) 383 | - Add custom CSS settings and view to theming control panel. 384 | Depends on https://github.com/plone/Products.CMFPlone/pull/3089 385 | [MrTango] (#178) 386 | 387 | 388 | Bug fixes: 389 | 390 | 391 | - Fix error on Python 3 with nonascii subrequest. 392 | The subrequest would succeed, but the non-ascii would be ugly. 393 | Fixes `issue 3069 `_ and `issue 162 `_. 394 | [maurits] (#162) 395 | - Make it possible to preview themes TTW again. 396 | [petri] (#173) 397 | - Fix hostnameBlacklist (Theming ControlPanel) in Py3. [MrTango] (#179) 398 | - Fix various ``WrongType`` exceptions when saving the control panel. 399 | This was introduced by the ``processInputs`` change in version 4.0.5. 400 | See `issue 183 `_. 401 | [maurits] (#183) 402 | 403 | 404 | 4.0.6 (2020-04-20) 405 | ------------------ 406 | 407 | Bug fixes: 408 | 409 | 410 | - Minor packaging updates. (#1) 411 | 412 | 413 | 4.0.5 (2020-03-13) 414 | ------------------ 415 | 416 | Bug fixes: 417 | 418 | 419 | - Do not call ``processInputs``. 420 | It is not needed since Zope 4, and not existing in Zope 5. 421 | [maurits] (#171) 422 | 423 | 424 | 4.0.4 (2019-12-11) 425 | ------------------ 426 | 427 | Bug fixes: 428 | 429 | 430 | - Fix creating a new theme ttw in py2 with Zope 4.1.3. 431 | [pbauer] (#166) 432 | 433 | 434 | 4.0.3 (2019-10-12) 435 | ------------------ 436 | 437 | Bug fixes: 438 | 439 | 440 | - Load zcml of ``plone.resource`` for our use of the ``plone:static`` directive. 441 | [maurits] (#2952) 442 | 443 | 444 | 4.0.2 (2019-09-13) 445 | ------------------ 446 | 447 | Bug fixes: 448 | 449 | 450 | - Fixed Python3 TypeError: 'filter' object is not subscriptable. 451 | This happened when overriding a filesystem theme with a TTW version 452 | [fredvd] (#160) 453 | 454 | 455 | 4.0.1 (2019-02-14) 456 | ------------------ 457 | 458 | Bug fixes: 459 | 460 | 461 | - Fix skinname-encoding in py3 (fixes 462 | https://github.com/plone/Products.CMFPlone/issues/2748) [pbauer] (#2748) 463 | 464 | 465 | 4.0.0 (2019-02-13) 466 | ------------------ 467 | 468 | Breaking changes: 469 | 470 | 471 | - Factor out all static resources into plone.staticresources as part of PLIP 472 | 1653. [thet, sunew] (#149) 473 | 474 | 475 | Bug fixes: 476 | 477 | 478 | - a11y: Added role attribute for portalMessage [nzambello] (#151) 479 | - Fixed DeprecationWarning about SafeConfigParser class on Python 3. [maurits] 480 | (#152) 481 | - Fixed ResourceWarnings for unclosed files in tests. [maurits] (#154) 482 | - Fixed "RuntimeError: dictionary changed size during iteration" [jensens] 483 | (#156) 484 | 485 | 486 | 3.0.1 (2018-12-11) 487 | ------------------ 488 | 489 | Breaking changes: 490 | 491 | - Remove five.globalrequest dependency. 492 | It has been deprecated upstream (Zope 4). 493 | [gforcada] 494 | 495 | 496 | 3.0.0 (2018-11-02) 497 | ------------------ 498 | 499 | New features: 500 | 501 | - Recompiled resource bundles with latest mockup. 502 | [sunew] 503 | 504 | Bug fixes: 505 | 506 | - Explicit load permissions for controlpanel. 507 | [jensens] 508 | 509 | - Fix tests for merged plone.login. 510 | [jensens] 511 | 512 | - More Python 3 fixes 513 | [ale-rt, pbauer, davisagli] 514 | 515 | 516 | 2.0.3 (2018-04-04) 517 | ------------------ 518 | 519 | Bug fixes: 520 | 521 | - Added a failing (5.1) test for fileuploads in the theme editor that breaks when plone.rest is installed. Fix is in https://github.com/plone/plone.rest/issues/59 522 | [djay] 523 | 524 | 525 | 2.0.2 (2018-02-04) 526 | ------------------ 527 | 528 | Bug fixes: 529 | 530 | - remove mention of non-existent Example theme 531 | [tkimnguyen] 532 | 533 | - Prepare for Python 2 / 3 compatibility 534 | [pbauer, ale-rt] 535 | 536 | 537 | 2.0.1 (2017-07-03) 538 | ------------------ 539 | 540 | Bug fixes: 541 | 542 | - Remove unittest2 dependency 543 | [kakshay21] 544 | 545 | 546 | 2.0 (2017-05-24) 547 | ---------------- 548 | 549 | Breaking changes: 550 | 551 | - Let the pattern configuration of the thememapper be in JSON format. 552 | Fixes problems of thememapper working together with latest patternslib (2.1.0). 553 | [thet] 554 | 555 | Bug fixes: 556 | 557 | - Fix thememapper pattern handling of buttons (via mockup update). 558 | Update thememapper bundle. 559 | [thet] 560 | 561 | 562 | 1.3.6 (2017-03-28) 563 | ------------------ 564 | 565 | Bug fixes: 566 | 567 | - Reduce log level of ThemingPolicy cache to 'debug'. 568 | [jensens] 569 | 570 | 571 | 1.3.5 (2017-02-12) 572 | ------------------ 573 | 574 | Bug fixes: 575 | 576 | - Fix imports from Globals that was removed in Zope4 577 | [pbauer] 578 | 579 | - No longer patch Control Panel internals, as it was removed in Zope4 580 | [MatthewWilkes] 581 | 582 | - reST syntax, styleguide, wording and line length of the docs 583 | [svx] 584 | 585 | 1.3.4 (2016-12-30) 586 | ------------------ 587 | 588 | Bug fixes: 589 | 590 | - Make diazo.debug work again when DIAZO_ALWAYS_CACHE_RULES is set. 591 | [ale-rt] 592 | 593 | 594 | 1.3.3 (2016-12-02) 595 | ------------------ 596 | 597 | Bug fixes: 598 | 599 | - Remove roman monkey patch. 600 | [gforcada] 601 | 602 | 1.3.2 (2016-09-23) 603 | ------------------ 604 | 605 | New features: 606 | 607 | - Add Update -button for theming control panel making it possible to 608 | reload modified theme manifest without deactivating theme at first. 609 | [datakurre] 610 | 611 | 612 | 1.3.1 (2016-09-07) 613 | ------------------ 614 | 615 | Fixes: 616 | 617 | - Enable unload protection by using pattern class ``pat-formunloadalert`` instead ``enableUnloadProtection``. 618 | [thet] 619 | 620 | - Small fix in documentation 621 | [staeff] 622 | 623 | - Fix issue where theming control panel errored when a packaged 624 | theme was overridden with a global resource directory theme 625 | [datakurre] 626 | 627 | 1.3.0 (2016-06-07) 628 | ------------------ 629 | 630 | New: 631 | 632 | - Control theme compilation in development mode 633 | through the environment variable ``DIAZO_ALWAYS_CACHE_RULES`` 634 | [ale-rt] 635 | 636 | Fixes: 637 | 638 | - Small fixes to documentation 639 | [ale-rt] 640 | 641 | 1.2.19 (2016-03-31) 642 | ------------------- 643 | 644 | New: 645 | 646 | - For the theming controlpanel, change base URLs from portal URL to what getSite returns, but don't change the controlpanels context binding. 647 | This allows for more flexibility when configuring it to be allowed on a sub site with a local registry. 648 | [thet] 649 | 650 | 651 | 1.2.18 (2016-03-03) 652 | ------------------- 653 | 654 | Fixes: 655 | 656 | - Fixed html validation: element nav does not need a role attribute. 657 | [maurits] 658 | 659 | - Handle potential scenarios where wrong theme would show selected in the theming 660 | control panel 661 | [vangheem] 662 | 663 | 664 | 1.2.17 (2016-02-11) 665 | ------------------- 666 | 667 | New: 668 | 669 | - Documented how to disable diazo transform by setting the 670 | ``X-Theme-Disabled`` header. [ale-rt] 671 | 672 | Fixes: 673 | 674 | - Rebuild resources so they work with latest mockup/patternslib 675 | integration changes. [vangheem] 676 | 677 | - Removed github dependencies in thememapper. [Gagaro] 678 | 679 | 680 | 1.2.16 (2015-11-26) 681 | ------------------- 682 | 683 | Fixes: 684 | 685 | - Updated Site Setup link in all control panels. 686 | Fixes https://github.com/plone/Products.CMFPlone/issues/1255 687 | [davilima6] 688 | 689 | 690 | 1.2.15 (2015-10-28) 691 | ------------------- 692 | 693 | Fixes: 694 | 695 | - Do not fail in ``isThemeEnabled`` when we have no settings, for 696 | example when migrating from Plone 3 to Plone 5, but maybe also in 697 | other cases. 698 | [maurits] 699 | 700 | - Fixed Unicode Encode Error when to copy into multi-byte title / description 701 | [terapyon] 702 | 703 | 704 | 1.2.14 (2015-09-27) 705 | ------------------- 706 | 707 | - Fix i18n in mapper.pt 708 | [vincentfretin] 709 | 710 | 711 | 1.2.13 (2015-09-20) 712 | ------------------- 713 | 714 | - Pull mark_special_links, external_links_open_new_window values 715 | from configuration registry. 716 | [esteele] 717 | 718 | - Fix visual glitch on Safari 719 | [davilima6] 720 | 721 | - Show active theme at the top of the theme list. 722 | Fixes https://github.com/plone/plone.app.theming/issues/70 723 | [tmassman] 724 | 725 | 726 | 1.2.12 (2015-09-15) 727 | ------------------- 728 | 729 | - Remove bundled twitter bootstrap theme 'example'. 730 | Fixes https://github.com/plone/Products.CMFPlone/issues/877 731 | [pbauer] 732 | 733 | - Remove duplicate type attribute for theming control panel delete modal. 734 | [esteele] 735 | 736 | 737 | 1.2.11 (2015-09-11) 738 | ------------------- 739 | 740 | - rewrite manifest from copied theme with relative paths also 741 | [vangheem] 742 | 743 | 744 | 1.2.10 (2015-09-08) 745 | ------------------- 746 | 747 | - theme mapper fixes for odd behavior in save files at times 748 | [swartz] 749 | 750 | 751 | 1.2.9 (2015-08-22) 752 | ------------------ 753 | 754 | - Build thememapper resources. 755 | [vangheem] 756 | 757 | - Added cache invalidation option. 758 | [swartz] 759 | 760 | 761 | 1.2.8 (2015-08-20) 762 | ------------------ 763 | 764 | - change link from plone.org to plone.com. 765 | [tkimnguyen] 766 | 767 | - fix toolbar on control panel 768 | [vangheem] 769 | 770 | - fix less building 771 | [obct537] 772 | 773 | - Fixed copy modal for themes with a dot in the name. 774 | [Gagaro] 775 | 776 | 777 | 1.2.7 (2015-07-18) 778 | ------------------ 779 | 780 | - Provide better styling to theming control panel, less build, finish implementation 781 | [obct537] 782 | 783 | - make sure when copying themes that you try to modify the base urls 784 | to match the new theme are all the manifest.cfg settings 785 | [vangheem] 786 | 787 | - implement switchable theming policy API, re-implement theme caching 788 | [gyst] 789 | 790 | - fixed configuration of copied theme 791 | [vmaksymiv] 792 | 793 | - implemented upload for theme manager 794 | [schwartz] 795 | 796 | - Change the category of the configlet to 'plone-general'. 797 | [sneridagh] 798 | 799 | 800 | 1.2.6 (2015-06-05) 801 | ------------------ 802 | 803 | - removed irrelevant theme renaming code 804 | [schwartz] 805 | 806 | - Filesystem themes are now correctly overridden. TTW themes can no longer be overridden 807 | [schwartz] 808 | 809 | - re-added manifest check 810 | [schwartz] 811 | 812 | - Fixed broken getTheme method 813 | [schwartz] 814 | 815 | - Minor ReStructuredText fixes for documentation. 816 | [maurits] 817 | 818 | 819 | 1.2.5 (2015-05-13) 820 | ------------------ 821 | 822 | - Fix RestructuredText representation on PyPI by bringing back a few 823 | example lines in the manifest. 824 | [maurits] 825 | 826 | 827 | 1.2.4 (2015-05-12) 828 | ------------------ 829 | 830 | - Add setting for tinymce automatically detected styles 831 | [vangheem] 832 | 833 | 1.2.3 (2015-05-04) 834 | ------------------ 835 | 836 | - fix AttributeError: 'NoneType' object has no attribute 'getroottree' when the result is not 837 | html / is empty. 838 | [sunew] 839 | 840 | - make control panel usable again. Fixed problem where skins 841 | control panel is no longer present. 842 | [vangheem] 843 | 844 | - unified different getTheme functions. 845 | [jensens] 846 | 847 | - pep8ified, housekeeping, cleanup 848 | [jensens] 849 | 850 | - Specify i18n:domain in controlpanel.pt. 851 | [vincentfretin] 852 | 853 | - pat-modal pattern has been renamed to pat-plone-modal 854 | [jcbrand] 855 | 856 | - Fix load pluginSettings for the enabled theme before calling plugins for 857 | onEnabled and call onEnabled plugins with correct parameters 858 | [datakurre] 859 | 860 | 861 | 1.2.2 (2015-03-22) 862 | ------------------ 863 | 864 | - Patch the ZMI only for available ZMI pages. 865 | [thet] 866 | 867 | - Change deprecated import of ``zope.site.hooks.getSite`` to 868 | ``zope.component.hooks.getSite``. 869 | [thet] 870 | 871 | - Add an error log if the subrequest failed (probably a relative xi:include) 872 | instead of silently returning None (and so having a xi:include returning 873 | nothing). 874 | [vincentfretin] 875 | 876 | - Fix transform to not affect the result when theming is disabled 877 | [datakurre] 878 | 879 | - Integrate thememapper mockup pattern and fix theming control panel 880 | to be more usable 881 | [ebrehault] 882 | 883 | 884 | 1.2.1 (2014-10-23) 885 | ------------------ 886 | 887 | - Remove DL's from portal message in templates. 888 | https://github.com/plone/Products.CMFPlone/issues/153 889 | [khink] 890 | 891 | - Fix "Insufficient Privileges" for "Site Administrators" on the control panel. 892 | [@rpatterson] 893 | 894 | - Add IThemeAppliedEvent 895 | [vangheem] 896 | 897 | - Put themes in a separate zcml file to be able to exclude them 898 | [laulaz] 899 | 900 | - #14107 bot requests like /widget/oauth_login/info.txt causes 901 | problems finding correct context with plone.app.theming 902 | [anthonygerrard] 903 | 904 | - Added support for ++theme++ to traverse to the contents of the 905 | current activated theme. 906 | [bosim] 907 | 908 | 909 | 1.2.0 (2014-03-02) 910 | ------------------ 911 | 912 | - Disable theming for manage_shutdown view. 913 | [davisagli] 914 | 915 | - Fix reference to theme error template 916 | [afrepues] 917 | 918 | - Add "Test Styles" button in control panel to expose, test_rendering template. 919 | [runyaga] 920 | 921 | 1.1.1 (2013-05-23) 922 | ------------------ 923 | 924 | - Fixed i18n issues. 925 | [thomasdesvenain] 926 | 927 | - Fixed i18n issues. 928 | [jianaijun] 929 | 930 | - This fixed UnicodeDecodeError when Theme Title is Non-ASCII 931 | in the manifest.cfg file. 932 | [jianaijun] 933 | 934 | 935 | 1.1 (2013-04-06) 936 | ---------------- 937 | 938 | - Fixed i18n issues. 939 | [vincentfretin] 940 | 941 | - Make the template theme do what it claims to do: copy styles as 942 | well as scripts. 943 | [smcmahon] 944 | 945 | - Change the label and description for the example theme to supply useful 946 | information. 947 | [smcmahon] 948 | 949 | - Upgrades from 1.0 get the combined "Theming" control panel that was added in 950 | 1.1a1. 951 | [danjacka] 952 | 953 | 954 | 1.1b2 (2013-01-01) 955 | ------------------ 956 | 957 | - Ensure host blacklist utilises SERVER_URL to correctly determine hostname 958 | for sites hosted as sub-folders at any depth. 959 | [davidjb] 960 | 961 | - Add test about plone.app.theming / plone.app.caching integration when 962 | using GZIP compression for anonymous 963 | (see ticket `12038 `_). [ebrehault] 964 | 965 | 966 | 1.1b1 (2012-10-16) 967 | ------------------ 968 | 969 | - Add diazo.debug option, route all error_log output through 970 | this so debugging can be displayed 971 | [lentinj] 972 | 973 | - Make example Bootstrap-based theme use the HTML5 DOCTYPE. 974 | [danjacka] 975 | 976 | - Demote ZMI patch log message to debug level. 977 | [hannosch] 978 | 979 | - Upgrade to ACE 1.0 via plone.resourceeditor 980 | [optilude] 981 | 982 | - Put quotes around jQuery attribute selector values to appease 983 | jQuery 1.7.2. 984 | [danjacka] 985 | 986 | 1.1a2 (2012-08-30) 987 | ------------------ 988 | 989 | - Protect the control panel with a specific permission so it can be 990 | delegated. 991 | [davisagli] 992 | 993 | - Advise defining ajax_load as ``request.form.get('ajax_load')`` in 994 | manifest.cfg. For instance, the login_form has an hidden empty 995 | ajax_load input, which would give an unthemed page after submitting 996 | the form. 997 | [maurits] 998 | 999 | - Change theme editor page templates to use main_template rather than 1000 | prefs_main_template to avoid inserting CSS and JavaScript too early 1001 | under plonetheme.classic. 1002 | [danjacka] 1003 | 1004 | 1.1a1 (2012-08-08) 1005 | ------------------ 1006 | 1007 | - Replace the stock "Themes" control panel with a renamed "Theming" control 1008 | panel, which incorporates the former's settings under its "Advanced" tab. 1009 | [optilude] 1010 | 1011 | - Add a full in-Plone theme authoring environment 1012 | [optilude, vangheem] 1013 | 1014 | - Update IBeforeTraverseEvent import to zope.traversing. 1015 | [hannosch] 1016 | 1017 | - On tab "Manage themes", change table header to 1018 | better describe what's actually listed. 1019 | [kleist] 1020 | 1021 | 1.0 (2012-04-15) 1022 | ---------------- 1023 | 1024 | * Prevent AttributeError when getRequest returns None. 1025 | [maurits] 1026 | 1027 | * Calculate subrequests against navigation root rather than portal. 1028 | [elro] 1029 | 1030 | * Supply closest context found for 404 pages. 1031 | [elro] 1032 | 1033 | * Lookup portal state with correct context. 1034 | [elro] 1035 | 1036 | 1.0b9 - 2011-11-02 1037 | ------------------ 1038 | 1039 | * Patch App.Management.Navigation to disable theming of ZMI pages. 1040 | [elro] 1041 | 1042 | 1.0b8 - 2011-07-04 1043 | ------------------ 1044 | 1045 | * Evaluate theme parameters regardless of whether there is a valid context or 1046 | not (e.g. when templating a 404 page). 1047 | [lentinj] 1048 | 1049 | 1.0b7 - 2011-06-12 1050 | ------------------ 1051 | 1052 | * Moved the *views* and *overrides* plugins out into a separate package 1053 | ``plone.app.themingplugins``. If you want to use those features, you need 1054 | to install that package in your buildout. Themes attempting to register 1055 | views or overrides in environments where ``plone.app.themingplugins`` is not 1056 | installed will install, but views and overrides will not take effect. 1057 | [optilude] 1058 | 1059 | 1.0b6 - 2011-06-08 1060 | ------------------ 1061 | 1062 | * Support for setting arbitrary Doctypes. 1063 | [elro] 1064 | 1065 | * Upgrade step to update plone.app.registry configuration. 1066 | [elro] 1067 | 1068 | * Fixed plugin initialization when applying a theme. 1069 | [maurits] 1070 | 1071 | * Query the resource directory using the 'currentTheme' name instead 1072 | of the Theme object (updating the control panel was broken). 1073 | [maurits] 1074 | 1075 | * Fix zip import (plugin initialization was broken.) 1076 | [elro] 1077 | 1078 | 1.0b5 - 2011-05-29 1079 | ------------------ 1080 | 1081 | * Make sure the control panel is never themed, by setting the X-Theme-Disabled 1082 | response header. 1083 | [optilude] 1084 | 1085 | * Add support for registering new views from Zope Page Templates and 1086 | overriding existing templates. See README for more details. 1087 | [optilude] 1088 | 1089 | 1.0b4 - 2011-05-24 1090 | ------------------ 1091 | 1092 | * Add support for ``X-Theme-Disabled`` response header. 1093 | [elro] 1094 | 1095 | * Make "Replace existing theme" checkbox default to off. 1096 | [elro] 1097 | 1098 | * Fix control panel to correctly display a newly uploaded theme. 1099 | [elro] 1100 | 1101 | * Fix zip import to work correctly when no manifest is supplied. 1102 | [elro] 1103 | 1104 | 1.0b3 - 2011-05-23 1105 | ------------------ 1106 | 1107 | * Show theme name along with title in control panel. 1108 | [elro] 1109 | 1110 | 1.0b2 - 2011-05-16 1111 | ------------------ 1112 | 1113 | * Encode internally resolved documents to support non-ascii characters 1114 | correctly. 1115 | [elro] 1116 | 1117 | * Fix control panel to use theme name not id. 1118 | [optilude] 1119 | 1120 | 1.0b1 - 2011-04-22 1121 | ------------------ 1122 | 1123 | * Wrap internal subrequests for css or js in style or script tags to 1124 | facilitate inline includes. 1125 | [elro] 1126 | 1127 | * Add ``theme.xml`` import step (see README). 1128 | [optilude] 1129 | 1130 | * Add support for ``[theme:parameters]`` section in ``manifest.cfg``, which 1131 | can be used to set parameters and the corresponding TALES expressions to 1132 | calculate them. 1133 | [optilude] 1134 | 1135 | * Add support for parameter expressions based on TALES expressions 1136 | [optilude] 1137 | 1138 | * Use plone.subrequest 1.6 features to work with IStreamIterator from 1139 | plone.resource. 1140 | [elro] 1141 | 1142 | * Depend on ``Products.CMFPlone`` instead of ``Plone``. 1143 | [elro] 1144 | 1145 | * Added support for uploading themes as Zip archives. 1146 | [optilude] 1147 | 1148 | * Added theme off switch: Add a query string parameter ``diazo.off=1`` to a 1149 | request whilst Zope is in development mode to turn off the theme. 1150 | [optilude] 1151 | 1152 | * Removed 'theme' and alternative themes support: Themes should be referenced 1153 | using the ```` directive in the Diazo rules file. 1154 | [optilude] 1155 | 1156 | * Removed 'domains' support: This can be handled with the rules file syntax 1157 | by using the ``host`` parameter. 1158 | [optilude] 1159 | 1160 | * Removed 'notheme' support: This can be handled within the rules file syntax 1161 | by using the ``path`` parameter. 1162 | [optilude] 1163 | 1164 | * Added ``path`` and ``host`` as parameters to the Diazo rules file. These 1165 | can now be used as conditional expressions. 1166 | [optilude] 1167 | 1168 | * Removed dependency on XDV in favour of dependency on Diazo (which is the 1169 | new name for XDV). 1170 | [optilude] 1171 | 1172 | * Forked from collective.xdv 1.0rc11. 1173 | [optilude] 1174 | --------------------------------------------------------------------------------