├── 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 |
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 |
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 |
50 | -
51 | Dashboard
57 |
58 | -
59 | Edit
65 |
66 |
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 |
--------------------------------------------------------------------------------