diff --git a/.github/workflows/django.yml b/.github/workflows/django.yml new file mode 100644 index 0000000..840d766 --- /dev/null +++ b/.github/workflows/django.yml @@ -0,0 +1,34 @@ +name: pytest-django CI + +on: + push: + # TODO: change that to "main" when merging + branches: [ "tetra-package" ] + pull_request: + branches: [ "tetra-package" ] + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + max-parallel: 4 + matrix: + python-version: [3.11, 3.12] + + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install Dependencies + run: | + python -m pip install --upgrade pip + python -m pip install .[dev] + cd tests + npm install + - name: Run Tests + run: | + cd tests + pytest \ No newline at end of file diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..2d02796 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,16 @@ +# Read the Docs configuration file for MkDocs projects +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +version: 2 + +build: + os: ubuntu-22.04 + tools: + python: "3.12" + +mkdocs: + configuration: mkdocs.yml + +python: + install: + - requirements: docs/requirements.txt \ No newline at end of file diff --git a/MANIFEST.in b/MANIFEST.in index e6c0202..ee85f0f 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,7 @@ recursive-include tetra/static * recursive-include tetra/js * recursive-include tetra/templates * -prune demosites \ No newline at end of file +prune demosite +prune .github +exclude mkdocs.yml +exclude .readthedocs.yaml diff --git a/README.md b/README.md index a5c4df7..b598a37 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,10 @@ Tetra is a new full stack component framework for Django, bridging the gap betwe See examples at [tetraframework.com](https://www.tetraframework.com) -Read the [Documentation](https://www.tetraframework.com/docs) +Read the [Documentation](https://tetra.readthedocs.org) ``` -pip install tetraframework +pip install tetra ``` ## What does Tetra do? diff --git a/demosite/.env.example b/demosite/.env.example new file mode 100644 index 0000000..f1dcd47 --- /dev/null +++ b/demosite/.env.example @@ -0,0 +1,5 @@ +DEBUG=True +SECRET_KEY= +# ALLOWED_HOSTS="www.tetraframework.com,tetraframework.com" +# CSRF_TRUSTED_ORIGINS="https://www.tetraframework.com,https://tetraframework.com" +# STATIC_ROOT="..." \ No newline at end of file diff --git a/demosite/demo/components.py b/demosite/demo/components.py index 8aa2eb9..66ef997 100644 --- a/demosite/demo/components.py +++ b/demosite/demo/components.py @@ -15,20 +15,23 @@ def load(self): self.todos = ToDo.objects.filter(session_key=self.request.session.session_key) @public - def add_todo(self, title): - todo = ToDo( - title=title, - session_key=self.request.session.session_key, - ) - todo.save() - self.title = "" + def add_todo(self, title: str): + if self.title: + todo = ToDo( + title=title, + session_key=self.request.session.session_key, + ) + todo.save() + self.title = "" template: django_html = """
- +
{% for todo in todos %} diff --git a/demosite/demo/static/demo/tetra/default/demo_default-GUWUK47B.js b/demosite/demo/static/demo/tetra/default/demo_default-JLCHQSL2.js similarity index 96% rename from demosite/demo/static/demo/tetra/default/demo_default-GUWUK47B.js rename to demosite/demo/static/demo/tetra/default/demo_default-JLCHQSL2.js index dbb2e43..2ee75b7 100644 --- a/demosite/demo/static/demo/tetra/default/demo_default-GUWUK47B.js +++ b/demosite/demo/static/demo/tetra/default/demo_default-JLCHQSL2.js @@ -1,2 +1,2 @@ (()=>{var _={lastTitleValue:"",inputDeleteDown(){this.lastTitleValue=this.title},inputDeleteUp(){this.title===""&&this.lastTitleValue===""&&this.delete_item()}};(()=>{let e={},t=[{name:"_refresh",endpoint:["/tetra/demo/default/to_do_list/_refresh"]},{name:"add_todo",endpoint:["/tetra/demo/default/to_do_list/add_todo"]}],n=["key","title"],o="demo__default__to_do_list";window.document.addEventListener("alpine:init",()=>{Tetra.makeAlpineComponent(o,e,t,n)})})();(()=>{let e=_,t=[{name:"_refresh",endpoint:["/tetra/demo/default/to_do_item/_refresh"]},{name:"save",watch:["title","done"],debounce:200,debounce_immediate:null,endpoint:["/tetra/demo/default/to_do_item/save"]},{name:"delete_item",endpoint:["/tetra/demo/default/to_do_item/delete_item"]}],n=["key","title","done"],o="demo__default__to_do_item";window.document.addEventListener("alpine:init",()=>{Tetra.makeAlpineComponent(o,e,t,n)})})();(()=>{let e={},t=[{name:"_refresh",endpoint:["/tetra/demo/default/counter/_refresh"]},{name:"increment",endpoint:["/tetra/demo/default/counter/increment"]}],n=["key"],o="demo__default__counter";window.document.addEventListener("alpine:init",()=>{Tetra.makeAlpineComponent(o,e,t,n)})})();(()=>{let e={},t=[{name:"_refresh",endpoint:["/tetra/demo/default/reactive_search/_refresh"]},{name:"watch_query",watch:["query"],throttle:200,throttle_trailing:!0,throttle_leading:null,endpoint:["/tetra/demo/default/reactive_search/watch_query"]}],n=["key","query"],o="demo__default__reactive_search";window.document.addEventListener("alpine:init",()=>{Tetra.makeAlpineComponent(o,e,t,n)})})();})(); -//# sourceMappingURL=demo_default-GUWUK47B.js.map +//# sourceMappingURL=demo_default-JLCHQSL2.js.map diff --git a/demosite/demo/static/demo/tetra/default/demo_default-GUWUK47B.js.map b/demosite/demo/static/demo/tetra/default/demo_default-JLCHQSL2.js.map similarity index 79% rename from demosite/demo/static/demo/tetra/default/demo_default-GUWUK47B.js.map rename to demosite/demo/static/demo/tetra/default/demo_default-JLCHQSL2.js.map index 7c524d4..82e85c7 100644 --- a/demosite/demo/static/demo/tetra/default/demo_default-GUWUK47B.js.map +++ b/demosite/demo/static/demo/tetra/default/demo_default-JLCHQSL2.js.map @@ -2,6 +2,6 @@ "version": 3, "sources": ["../../../../components.py__to_do_item.js", "../../../../__tetracache__/default/demo_default.js"], "sourcesContent": [" \n \n \n \n \n\n \n\n\n \n \n \n\n \n \n\n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n\n \n \n \n \n\n \n \n \n \n\n \n \n \n \n \n \n\n \n \n \n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n export default {\n lastTitleValue: \"\",\n inputDeleteDown() {\n this.lastTitleValue = this.title;\n },\n inputDeleteUp() {\n if (this.title === \"\" && this.lastTitleValue === \"\") {\n this.delete_item()\n }\n }\n }\n ", "import to_do_item from \"../../components.py__to_do_item.js\";\n\n(() => {\n let __script = {};\n let __serverMethods = [{\"name\": \"_refresh\", \"endpoint\": [\"/tetra/demo/default/to_do_list/_refresh\"]}, {\"name\": \"add_todo\", \"endpoint\": [\"/tetra/demo/default/to_do_list/add_todo\"]}];\n let __serverProperties = [\"key\", \"title\"];\n let __componentName = 'demo__default__to_do_list';\n window.document.addEventListener('alpine:init', () => {\n Tetra.makeAlpineComponent(\n __componentName,\n __script,\n __serverMethods,\n __serverProperties,\n )\n })\n})();\n(() => {\n let __script = to_do_item;\n let __serverMethods = [{\"name\": \"_refresh\", \"endpoint\": [\"/tetra/demo/default/to_do_item/_refresh\"]}, {\"name\": \"save\", \"watch\": [\"title\", \"done\"], \"debounce\": 200, \"debounce_immediate\": null, \"endpoint\": [\"/tetra/demo/default/to_do_item/save\"]}, {\"name\": \"delete_item\", \"endpoint\": [\"/tetra/demo/default/to_do_item/delete_item\"]}];\n let __serverProperties = [\"key\", \"title\", \"done\"];\n let __componentName = 'demo__default__to_do_item';\n window.document.addEventListener('alpine:init', () => {\n Tetra.makeAlpineComponent(\n __componentName,\n __script,\n __serverMethods,\n __serverProperties,\n )\n })\n})();\n(() => {\n let __script = {};\n let __serverMethods = [{\"name\": \"_refresh\", \"endpoint\": [\"/tetra/demo/default/counter/_refresh\"]}, {\"name\": \"increment\", \"endpoint\": [\"/tetra/demo/default/counter/increment\"]}];\n let __serverProperties = [\"key\"];\n let __componentName = 'demo__default__counter';\n window.document.addEventListener('alpine:init', () => {\n Tetra.makeAlpineComponent(\n __componentName,\n __script,\n __serverMethods,\n __serverProperties,\n )\n })\n})();\n(() => {\n let __script = {};\n let __serverMethods = [{\"name\": \"_refresh\", \"endpoint\": [\"/tetra/demo/default/reactive_search/_refresh\"]}, {\"name\": \"watch_query\", \"watch\": [\"query\"], \"throttle\": 200, \"throttle_trailing\": true, \"throttle_leading\": null, \"endpoint\": [\"/tetra/demo/default/reactive_search/watch_query\"]}];\n let __serverProperties = [\"key\", \"query\"];\n let __componentName = 'demo__default__reactive_search';\n window.document.addEventListener('alpine:init', () => {\n Tetra.makeAlpineComponent(\n __componentName,\n __script,\n __serverMethods,\n __serverProperties,\n )\n })\n})();"], - "mappings": "MAqFI,GAAO,GAAQ,CACX,eAAgB,GAChB,iBAAkB,CACd,KAAK,eAAiB,KAAK,KAC/B,EACA,eAAgB,CACZ,AAAI,KAAK,QAAU,IAAM,KAAK,iBAAmB,IAC7C,KAAK,YAAY,CAEzB,CACJ,EC7FJ,AAAC,KAAM,CACL,GAAI,GAAW,CAAC,EACZ,EAAkB,CAAC,CAAC,KAAQ,WAAY,SAAY,CAAC,yCAAyC,CAAC,EAAG,CAAC,KAAQ,WAAY,SAAY,CAAC,yCAAyC,CAAC,CAAC,EAC/K,EAAqB,CAAC,MAAO,OAAO,EACpC,EAAkB,4BACtB,OAAO,SAAS,iBAAiB,cAAe,IAAM,CACpD,MAAM,oBACJ,EACA,EACA,EACA,CACF,CACF,CAAC,CACH,GAAG,EACH,AAAC,KAAM,CACL,GAAI,GAAW,EACX,EAAkB,CAAC,CAAC,KAAQ,WAAY,SAAY,CAAC,yCAAyC,CAAC,EAAG,CAAC,KAAQ,OAAQ,MAAS,CAAC,QAAS,MAAM,EAAG,SAAY,IAAK,mBAAsB,KAAM,SAAY,CAAC,qCAAqC,CAAC,EAAG,CAAC,KAAQ,cAAe,SAAY,CAAC,4CAA4C,CAAC,CAAC,EACrU,EAAqB,CAAC,MAAO,QAAS,MAAM,EAC5C,EAAkB,4BACtB,OAAO,SAAS,iBAAiB,cAAe,IAAM,CACpD,MAAM,oBACJ,EACA,EACA,EACA,CACF,CACF,CAAC,CACH,GAAG,EACH,AAAC,KAAM,CACL,GAAI,GAAW,CAAC,EACZ,EAAkB,CAAC,CAAC,KAAQ,WAAY,SAAY,CAAC,sCAAsC,CAAC,EAAG,CAAC,KAAQ,YAAa,SAAY,CAAC,uCAAuC,CAAC,CAAC,EAC3K,EAAqB,CAAC,KAAK,EAC3B,EAAkB,yBACtB,OAAO,SAAS,iBAAiB,cAAe,IAAM,CACpD,MAAM,oBACJ,EACA,EACA,EACA,CACF,CACF,CAAC,CACH,GAAG,EACH,AAAC,KAAM,CACL,GAAI,GAAW,CAAC,EACZ,EAAkB,CAAC,CAAC,KAAQ,WAAY,SAAY,CAAC,8CAA8C,CAAC,EAAG,CAAC,KAAQ,cAAe,MAAS,CAAC,OAAO,EAAG,SAAY,IAAK,kBAAqB,GAAM,iBAAoB,KAAM,SAAY,CAAC,iDAAiD,CAAC,CAAC,EACzR,EAAqB,CAAC,MAAO,OAAO,EACpC,EAAkB,iCACtB,OAAO,SAAS,iBAAiB,cAAe,IAAM,CACpD,MAAM,oBACJ,EACA,EACA,EACA,CACF,CACF,CAAC,CACH,GAAG", - "names": [] + "mappings": "MAqFI,IAAOA,EAAQ,CACX,eAAgB,GAChB,iBAAkB,CACd,KAAK,eAAiB,KAAK,KAC/B,EACA,eAAgB,CACR,KAAK,QAAU,IAAM,KAAK,iBAAmB,IAC7C,KAAK,YAAY,CAEzB,CACJ,GC7FH,IAAM,CACL,IAAIC,EAAW,CAAC,EACZC,EAAkB,CAAC,CAAC,KAAQ,WAAY,SAAY,CAAC,yCAAyC,CAAC,EAAG,CAAC,KAAQ,WAAY,SAAY,CAAC,yCAAyC,CAAC,CAAC,EAC/KC,EAAqB,CAAC,MAAO,OAAO,EACpCC,EAAkB,4BACtB,OAAO,SAAS,iBAAiB,cAAe,IAAM,CACpD,MAAM,oBACJA,EACAH,EACAC,EACAC,CACF,CACF,CAAC,CACH,GAAG,GACF,IAAM,CACL,IAAIF,EAAWI,EACXH,EAAkB,CAAC,CAAC,KAAQ,WAAY,SAAY,CAAC,yCAAyC,CAAC,EAAG,CAAC,KAAQ,OAAQ,MAAS,CAAC,QAAS,MAAM,EAAG,SAAY,IAAK,mBAAsB,KAAM,SAAY,CAAC,qCAAqC,CAAC,EAAG,CAAC,KAAQ,cAAe,SAAY,CAAC,4CAA4C,CAAC,CAAC,EACrUC,EAAqB,CAAC,MAAO,QAAS,MAAM,EAC5CC,EAAkB,4BACtB,OAAO,SAAS,iBAAiB,cAAe,IAAM,CACpD,MAAM,oBACJA,EACAH,EACAC,EACAC,CACF,CACF,CAAC,CACH,GAAG,GACF,IAAM,CACL,IAAIF,EAAW,CAAC,EACZC,EAAkB,CAAC,CAAC,KAAQ,WAAY,SAAY,CAAC,sCAAsC,CAAC,EAAG,CAAC,KAAQ,YAAa,SAAY,CAAC,uCAAuC,CAAC,CAAC,EAC3KC,EAAqB,CAAC,KAAK,EAC3BC,EAAkB,yBACtB,OAAO,SAAS,iBAAiB,cAAe,IAAM,CACpD,MAAM,oBACJA,EACAH,EACAC,EACAC,CACF,CACF,CAAC,CACH,GAAG,GACF,IAAM,CACL,IAAIF,EAAW,CAAC,EACZC,EAAkB,CAAC,CAAC,KAAQ,WAAY,SAAY,CAAC,8CAA8C,CAAC,EAAG,CAAC,KAAQ,cAAe,MAAS,CAAC,OAAO,EAAG,SAAY,IAAK,kBAAqB,GAAM,iBAAoB,KAAM,SAAY,CAAC,iDAAiD,CAAC,CAAC,EACzRC,EAAqB,CAAC,MAAO,OAAO,EACpCC,EAAkB,iCACtB,OAAO,SAAS,iBAAiB,cAAe,IAAM,CACpD,MAAM,oBACJA,EACAH,EACAC,EACAC,CACF,CACF,CAAC,CACH,GAAG", + "names": ["components_py_to_do_item_default", "__script", "__serverMethods", "__serverProperties", "__componentName", "components_py_to_do_item_default"] } diff --git a/demosite/demo/static/demo/tetra/default/demo_default.js.filename b/demosite/demo/static/demo/tetra/default/demo_default.js.filename index 7987ddf..d7ece7f 100644 --- a/demosite/demo/static/demo/tetra/default/demo_default.js.filename +++ b/demosite/demo/static/demo/tetra/default/demo_default.js.filename @@ -1 +1 @@ -demo_default-GUWUK47B.js \ No newline at end of file +demo_default-JLCHQSL2.js \ No newline at end of file diff --git a/demosite/demosite/settings.py b/demosite/demosite/settings.py index 8e24896..ec517b3 100644 --- a/demosite/demosite/settings.py +++ b/demosite/demosite/settings.py @@ -12,26 +12,31 @@ import os from pathlib import Path +import environ + +env = environ.Env( + # set casting, default value + DEBUG=(bool, False) +) # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent +# Take environment variables from .env file +environ.Env.read_env(os.path.join(BASE_DIR, ".env")) + # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/4.0/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = os.environ.get("SECRET_KEY", "insecure!") +# Raises Django's ImproperlyConfigured exception if SECRET_KEY not in os.environ +SECRET_KEY = env("SECRET_KEY") # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = os.getenv("DEBUG", False) == "True" +DEBUG = env("DEBUG") +ALLOWED_HOSTS = env.list("ALLOWED_HOSTS", default=["127.0.0.1", "localhost"]) -ALLOWED_HOSTS = [ - "localhost", - "127.0.0.1", - "www.tetraframework.com", - "tetraframework.com", -] -CSRF_TRUSTED_ORIGINS = ["https://www.tetraframework.com", "https://tetraframework.com"] +CSRF_TRUSTED_ORIGINS = env.list("CSRF_TRUSTED_ORIGINS", default=[]) # Application definition @@ -50,6 +55,7 @@ MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", @@ -126,8 +132,16 @@ # https://docs.djangoproject.com/en/4.0/howto/static-files/ STATIC_URL = "static/" -STATIC_ROOT = BASE_DIR / "static" -STATICFILES_STORAGE = "django.contrib.staticfiles.storage.ManifestStaticFilesStorage" +STATIC_ROOT = env("STATIC_ROOT", default=BASE_DIR / "static") + +# STATICFILES_STORAGE = "django.contrib.staticfiles.storage.ManifestStaticFilesStorage" +STORAGES = ( + { + "staticfiles": { + "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage", + }, + }, +) # Default primary key field type # https://docs.djangoproject.com/en/4.0/ref/settings/#default-auto-field diff --git a/dev-requirements.txt b/dev-requirements.txt deleted file mode 100644 index ba53104..0000000 --- a/dev-requirements.txt +++ /dev/null @@ -1,11 +0,0 @@ -black -cryptography>=37.0.1 -Django>=3.2.0 -gunicorn==20.1.0 -Markdown==3.3.7 -python-dateutil>=2.8.2 -PyYAML==6.0 -setuptools -sourcetypes>=0.0.4 -twine -wheel diff --git a/docs/attribute-tag.md b/docs/attribute-tag.md index d14c19c..cfec426 100644 --- a/docs/attribute-tag.md +++ b/docs/attribute-tag.md @@ -1,4 +1,6 @@ -Title: Attribute Tag +--- +title: "The `...` Attribute" +--- # `...` Attribute Tag @@ -12,7 +14,7 @@ The attributes tag is automaticity available in your component templates. In oth checked=a_boolian_varible %}> ``` -All Tetra components have a `attrs` context available, which is a `dict` of attributes that have been passed to the component when it is included in a template with the [`@` tag](component-tag). It can be unpacked as HTML attributes on your root node: +All Tetra components have an `attrs` context available, which is a `dict` of attributes that have been passed to the component when it is included in a template with the [`@` tag](component-tag.md). It can be unpacked as HTML attributes on your root node: ``` django
@@ -68,14 +70,15 @@ Would result in:
``` -> **Note:** Tetra currently does not understand that a style property can be applied in multiple ways. Therefore, if you pass both `margin-top: 1em` and `margin: 2em 0 0 0`, both will appear in the final HTML style tag, with the final property taking precedence in the browser. +!!! note + Tetra currently does not understand that a style property can be applied in multiple ways. Therefore, if you pass both `margin-top: 1em` and `margin: 2em 0 0 0`, both will appear in the final HTML style tag, with the final property taking precedence in the browser. ## Conditional values -The [`if` and `else` template filters](if-else-filters) are provided to enable conditional attribute values: +The [`if` and `else` template filters](if-else-filters.md) are provided to enable conditional attribute values: ``` html
``` -See the documentation for the [`if` and `else` template filters](if-else-filters). +See the documentation for the [`if` and `else` template filters](if-else-filters.md). diff --git a/docs/basic-components.md b/docs/basic-components.md index 25f9a44..8d4a00e 100644 --- a/docs/basic-components.md +++ b/docs/basic-components.md @@ -1,4 +1,6 @@ -Title: Basic Components +--- +title: Basic Components +--- # Basic Components diff --git a/docs/changelog.md b/docs/changelog.md index d2cf7c0..78a884c 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,25 +1,55 @@ -Title: Change Log +--- +title: Changelog +--- + +# Changelog + +!!! note + Tetra is still early in its development, and we can make no promises about + API stability at this stage. + + The intention is to stabilise the API prior to a v1.0 release, as well as + implementing some additional functionality. + After v1.0 we will move to using [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.1.0] - unreleased +### Changes +- **New package name: tetra** +- add conditional block check within components +- Update Alpine.js to v3.13.8 +- switch to pyproject.toml based python package +- improve demo project: TodoList component,- add django-environ for keeping secrets, use whitenoise for staticfiles +- give users more hints when no components are found +- MkDocs based documentation +- format codebase with Black -# Change Log +### Added +- basic testing using pytest -> **Note:** Tetra is still early in its development, and we can make no promises about API stability at this stage. -> -> The intention is to stabilise the API prior to a V1 release this summer, as well as implementing some additional functionality. After V1 we will move to using [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +### Fixed +- correctly find components -## [0.0.3] - 2022-5-29 +## [0.0.5] - unreleased +### Changed +- **This is the last package with the name "tetraframework", transition to "tetra"** +- Provisional Python 3.8 support -### Added +### Fixed +- Windows support -- `_parent` client attribute added, this can be used to access the parent component mounted in the client. -- `_redirect` client method added, this can be used to redirect to another url from the public server methods. `self.client._redirect("/test")` would redirect to the `/test` url. +## [0.0.4] - 2022-06-22 +- Cleanup -- `_dispatch` client method added, this is a wrapper around the Alpine.js [`dispatch` magic](https://alpinejs.dev/magics/dispatch) allowing you to dispatch events from public server methods. These bubble up the DOM and be captured by listeners on (grand)parent components. Example: `self.client._dispatch("MyEvent", {'some_data': 123})`. +## [0.0.3] - 2022-05-29 +### Added +- `_parent` client attribute added, this can be used to access the parent component mounted in the client. +- `_redirect` client method added, this can be used to redirect to another url from the public server methods. `self.client._redirect("/test")` would redirect to the `/test` url. +- `_dispatch` client method added, this is a wrapper around the Alpine.js [`dispatch` magic](https://alpinejs.dev/magics/dispatch) allowing you to dispatch events from public server methods. These bubble up the DOM and be captured by listeners on (grand)parent components. Example: `self.client._dispatch("MyEvent", {'some_data': 123})`. - `_refresh` public method added, this simply renders the component on the server updating the dom in the browser. This can be used in combination with `_parent` to instruct a parent component to re-render from a child components public method such as: `self.client._parent._refresh()` ### Changed - - Built in Tetra client methods renamed to be prefixed with an underscore so that they are separated from user implemented methods: - `updateHtml` is now `_updateHtml` - `updateData` is now `_updateData` diff --git a/docs/component-inheritance.md b/docs/component-inheritance.md new file mode 100644 index 0000000..4c00f38 --- /dev/null +++ b/docs/component-inheritance.md @@ -0,0 +1,38 @@ +Title: Component Inheritance + +# Component Inheritance + +Components are inheritable, to create components that bundle common features, which can be reused and extended by more +specialized ones. + +The only thing you have to conform to is: **You cannot inherit from already registered components.** +So create a "base" component without registering it, and just register the inherited components. + +This works with both `BasicComponent` and `Component`. + +``` python +# no registering here! +class CardBase(BasicComponent): + template: django_html = """ +
+ ... +
+ """ + +# now register the components +@default.register +class Card(CardBase): + template: django_html = """ +
+ {% block default %]{% endblock %]} +
+ """ + +@default.register +class GreenCard(CardBase): + style: css = """ + .mycard { + background-color: green + } + """ +``` \ No newline at end of file diff --git a/docs/component-libraries.md b/docs/component-libraries.md index 8f37589..4b23200 100644 --- a/docs/component-libraries.md +++ b/docs/component-libraries.md @@ -1,4 +1,6 @@ -Title: Component Libraries +--- +title: Libraries +--- # Component Libraries diff --git a/docs/component-tag.md b/docs/component-tag.md index f3fbcc6..015a56f 100644 --- a/docs/component-tag.md +++ b/docs/component-tag.md @@ -1,4 +1,6 @@ -Title: Component Tag +--- +title: The `@` Component Tag +--- # `@` Component Tag @@ -67,7 +69,7 @@ It is common to want to set HTML attributes on the root element of your componen {% @ my_component attrs: class="my-class" style="font-wight:bold;" / %} ``` -These are made available as `attrs` in the component's template, the [attribute `...` tag](attribute-tag) should be used to unpack them into the root tag, its [docs](attribute-tag) details how this works. +These are made available as `attrs` in the component's template, the [attribute `...` tag](attribute-tag.md) should be used to unpack them into the root tag, its [docs](attribute-tag.md) details how this works. ## Passing Context @@ -83,9 +85,9 @@ It is also possible to explicitly pass all template context to a component with {% @ my_component context: **context / %} ``` -This should be used sparingly as the whole template context will be saved with the component's saved (encrypted) state, and sent to the client, see [state security](state-security). +This should be used sparingly as the whole template context will be saved with the component's saved (encrypted) state, and sent to the client, see [state security](state-security.md). -In general, if the value is something that is needed for the component to function (and be available to methods or be "public") it should be passed as an *argument* [(see above)](#passing-attributes). Passing context is ideal for composing your components with inner content passed down from an outer template [(see passing blocks)](#passing-blocks). +In general, if the value is something that is needed for the component to function (and be available to methods or be "public") it should be passed as an *argument* [(see above)](#passing-attributes.md). Passing context is ideal for composing your components with inner content passed down from an outer template [(see passing blocks)](#passing-blocks). ## Passing Blocks @@ -154,4 +156,4 @@ You can also specify under what name a block should be exposed with `expose as [ {% /@ my_component %} ``` -See [component templates](components#templates) for details of how the component handles blocks in its templates. +See [component templates](components.md#templates) for details of how the component handles blocks in its templates. diff --git a/docs/components.md b/docs/components.md index ccf15b1..57940b6 100644 --- a/docs/components.md +++ b/docs/components.md @@ -1,8 +1,9 @@ -Title: Components - +--- +title: Components +--- # Components -A component is created as a subclass of `BasicComponent` or `Component` and registered to a library with the `@libraryname.register` decorator, see [component libraries](component-libraries). +A component is created as a subclass of `BasicComponent` or `Component` and registered to a library with the `@libraryname.register` decorator, see [component libraries](component-libraries.md). ``` python # yourapp/components.py @@ -16,7 +17,7 @@ class MyComponent(Component): ... ``` -Attributes on a component are standard Python types. When the component is rendered, the state of the whole class is saved (using Pickle, see [state security](state-security)) to enable resuming the component with its full state when public method calls are made by the browser. +Attributes on a component are standard Python types. When the component is rendered, the state of the whole class is saved (using Pickle, see [state security](state-security.md)) to enable resuming the component with its full state when public method calls are made by the browser. ``` python @default.register @@ -37,11 +38,11 @@ class MyComponent(Component): ## Load method -The `load` method is run both when the component initiates *and* after it is resumed from its saved state. Any attributes that are set by the load method are *not* saved with the state. This is to reduce the size of the state and ensure that the state is not stale when resumed. +The `load` method is run both when the component initiates, *and* after it is resumed from its saved state, e.g. after a [@public method](#public-methods) has finished. Any attributes that are set by the load method are *not* saved with the state. This is to reduce the size of the state and ensure that the state is not stale when resumed. -Arguments are passed to the `load` method from the Tetra [component "`@`" template tag](component-tag). Arguments are saved with the state so that when the component is resumed the `load` method will receive the same values. +Arguments are passed to the `load` method from the Tetra [component "`@`" template tag](component-tag.md). Arguments are saved with the state so that when the component is resumed the `load` method will receive the same values. -Note: Django Models and Querysets are saved as references to your database, not the current 'snapshots', see [state optimisations](state-security#state-optimisations). +Note: Django Models and Querysets are saved as references to your database, not the current 'snapshots', see [state optimisations](state-security.md#state-optimisations). ``` python @default.register @@ -108,16 +109,22 @@ class MyComponent(Component): ### .watch -Public methods can "watch" public attributes and be called automatically when they change. They can watch multiple attributes by passing multiple names to `.watch()`. +Public methods can "watch" public attributes and be called automatically when they change. +They can watch multiple attributes by passing multiple names to `.watch()`. ``` python @default.register class MyComponent(Component): ... @public.watch("message") - def message_change(self, value, new_value, attr): + def message_change(self, value, old_value, attr): self.a_value = f"Your message is: {message}" ``` +When the `.watch` decorator is applied, the method receives 3 parameters: + +* *value*: The current value of the attribute +* *old_value*: The old value of the attribute before the change. You can make comparisons here. +* *attr*: The name of the attribute. This is needed, if the method is watching more than one attributes. ### .debounce @@ -132,11 +139,14 @@ class MyComponent(Component): class MyComponent(Component): ... @public.watch("message").debounce(200) - def message_change(self, value, new_value, attr): + def message_change(self, value, old_value, attr): self.a_value = f"Your message is: {message}" ``` -> **Node:** on Python versions prior to 3.9 the chained decorator syntax above is invalid (see [PEP 614](https://peps.python.org/pep-0614/)). On older versions you can apply the decorator multiple times with each method required: +!!! note + On Python versions prior to 3.9 the chained decorator syntax above is invalid + (see [PEP 614](https://peps.python.org/pep-0614/)). + On older versions you can apply the decorator multiple times with each method required: ``` python @default.register @@ -144,11 +154,11 @@ class MyComponent(Component): ... @public.watch("message") @public.debounce(200) - def message_change(self, value, new_value, attr): + def message_change(self, value, old_value, attr): self.a_value = f"Your message is: {message}" ``` -###.throttle +### .throttle You can add `.throttle(ms)` to throttle the calling of the method. @@ -159,19 +169,37 @@ class MyComponent(Component): class MyComponent(Component): ... @public.watch("message").throttle(200, trailing=True) - def message_change(self, value, new_value, attr): + def message_change(self, value, old_value, attr): self.a_value = f"Your message is: {message}" ``` ## Templates -The `template` attribute is the Django template for the component in string form. Tetra template tags are automatically made available to your component templates, and all attributes and methods of the component are available in the context. +### Template types + +Tetra components supports two different template types: + +#### Inline string templates + +If the component has a `template` attribute, it is used as Django template for the component in string form. +Tetra template tags are automatically made available to your inline templates, and all attributes and methods of the +component are available in the context. + +#### File templates + +You can also use the more traditional way and put your HTML code into a separate HTML file. You have to point to this +file using the `template_name` attribute of the component class. Beware that you have to load the `tetra` templatetag +yourself there. This has the advantage of having full syntax highlighting and IDE goodies support in your file which +comes handy for especially bigger templates, but it splits a component a bit up into separate pieces. + + +### Generic template hints Components must have a single top level HTML root node. -HTML attributes passed to the component `@` tag are available as `attrs` in the context, this can be unpacked with the [attribute `...` tag](attribute-tag). +HTML attributes passed to the component `@` tag are available as `attrs` in the context, this can be unpacked with the [attribute `...` tag](attribute-tag.md). -The template can contain replaceable `{% block(s) %}`, the `default` block is the target block if no block is specified when including a component in a page with inner content. This is similar to "slots" in other component frameworks. See [passing blocks](component-tag#passing-blocks) for more details. +The template can contain replaceable `{% block(s) %}`, the `default` block is the target block if no block is specified when including a component in a page with inner content. This is similar to "slots" in other component frameworks. See [passing blocks](component-tag.md#passing-blocks) for more details. You can use the [Python Inline Source Syntax Highlighting](https://marketplace.visualstudio.com/items?itemName=samwillis.python-inline-source) VS Code extension to syntax highlight the inline HTML, CSS and JavaScript in your component files using type annotations. @@ -186,6 +214,9 @@ class MyComponent(Component): {% block default %}{% endblock %}
""" + + # or: + template_name = "my_app/components/my_component.html" ``` You can easily check if a block is "filled" with content by using `{% if blocks. %}`. With this, you can @@ -208,7 +239,7 @@ bypass wrapping elements when a block was not used: The `script` attribute holds the client side Alpine.js JavaScript for your component. It should use `export default` to export an object forming the [Alpine.js component "Data"](https://alpinejs.dev/globals/alpine-data). This will be extended with your public attributes and methods. -It can contain all standard Alpine methods such as `init` +It can contain all standard Alpine methods such as `init`. Other JavaScript files can be imported using standard `import` syntax relative to the source file. @@ -250,7 +281,8 @@ class MyComponent(Component): """ ``` -> The plan is to add support for PostCSS and tools such as SASS and LESS in future, along with component scoped CSS in future. +!!! note + The plan is to add support for PostCSS and tools such as SASS and LESS in future, along with component scoped CSS in future. ## `client` API @@ -271,11 +303,12 @@ class MyComponent(Component): alert(msg) } } + """ ``` If is implemented as a queue that is sent to the client after thee public method returns. The client they calls all scheduled callbacks with the provided arguments. -Arguments must be of the same types as our extended JSON, see [public attributes](public-attributes) for details. +Arguments must be of the same types as our extended JSON, see [public attributes](#public-attributes) for details. ## Built in client methods @@ -320,7 +353,7 @@ class MyComponent(Component): self.client._redirect('/another-url') ``` -This can be combined with [Django's `reverse()`](https://docs.djangoproject.com/en/4.0/ref/urlresolvers/#reverse) function: +This can be combined with [Django's `reverse()`](https://docs.djangoproject.com/en/4.2/ref/urlresolvers/#reverse) function: ``` python @default.register @@ -419,11 +452,11 @@ class MyComponent(Component): ## Built in server methods -There are a number of built in server methods: +There are a number of built-in server methods: ### `update` -The `update` method instructs the component to rerender after the public method has completed, sending the updated html to the browser and "morphing" the DOM. Usually public methods do this by default, however if this has been turned off and you want to conditionally update the html you can use this: +The `update` method instructs the component to rerender after the public method has completed, sending the updated HTML to the browser and "morphing" the DOM. Usually public methods do this by default. However, if this has been turned off with `update=False`, and you want to conditionally update the html, you can use this: ``` python @default.register @@ -438,7 +471,7 @@ class MyComponent(Component): ### `update_data` -The `update_data` method instructs the componet to send the complete set of public attributes to the client updateing their values, usefull to be used in combination with `@public(update=False)`: +The `update_data` method instructs the component to send the complete set of public attribute to the client, updating their values, useful in combination with `@public(update=False)`: ``` python @default.register @@ -449,7 +482,59 @@ class MyComponent(Component): ... # Do stuff, then self.update_data() ``` +This way, no component re-rendering in the browser is triggered, just the values itself are updated. ### `replace_component` -This removes and destroys the component in the browser and re-inserts a new copy into the DOM. Any client side state, such as cursor location in text inputs will be lost. +This removes and destroys the component in the browser and re-inserts a new copy into the DOM. Any client side state, +such as cursor location in text inputs will be lost. + +## Combining Alpine.js and backend methods + +Alpine functionality and Tetra components' backend methods can bee freely combined, so you can use the advantages of +each of them. + +This code creates a password input control with an inline "Show/Hide password" button. This is done using Alpine.js, just +on the client - it would use too much overhead to send a request to the server just for toggling a password view. But +additionally, the component calls the server side check method to monitor if the user has entered a valid password. + +```python +@default.register +class PasswordInput(Component): + visible: bool = public(False) + password: str = public("") + valid: bool = public(True) + feedback_text: str = "" + + @public.watch("password").debounce(500) + def check(self, value, old_value, attr_name): + """Check password validity""" + if len(value) > 12: + self.valid = True + self.feedback_text = "Password has more than 12 chars." + else: + self.valid = False + self.feedback_text = "Error: Password must have more than 12 chars." + + template: django_html = """ +
+
+ + + + + + +
+
+ {{ feedback_text }}
+
+ """ + +``` + diff --git a/docs/contribute.md b/docs/contribute.md index 9327420..9a0e3c3 100644 --- a/docs/contribute.md +++ b/docs/contribute.md @@ -1,4 +1,6 @@ -Title: Contributing +--- +title: Contributing +--- # Contributing to the project @@ -7,8 +9,6 @@ You can help/contribute in many ways: * Bring in new ideas and [discussions](https://github.com/tetra-framework/tetra/discussions) * Report bugs in our [issue tracker](https://github.com/tetra-framework/tetra/issues) * Add documentation -* Have an idea/paint a logo for the project - do that in - [discussions](https://github.com/tetra-framework/tetra/discussions) too. * Write code @@ -25,5 +25,7 @@ python -m pip install -e . ### Code style -Please only write [Black](https://github.com/psf/black) styled code. You can automate that by using your IDE's save +* Please only write [Black](https://github.com/psf/black) styled code. You can automate that by using your IDE's save trigger feature. +* Document your code well, using [Napoleon style docstrings](https://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_google.html#example-google). +* Write appropriate tests for your code. \ No newline at end of file diff --git a/docs/if-else-filters.md b/docs/if-else-filters.md index f43e468..bf9f7d4 100644 --- a/docs/if-else-filters.md +++ b/docs/if-else-filters.md @@ -1,8 +1,10 @@ -Title: if and else Template Filters +--- +title: if & else filters +--- # `if` and `else` Template Filters -The `if` and `else` template filters are provided to enable conditional attribute values with the [`...` attribute template tag](attribute-tag): +The `if` and `else` template filters are provided to enable conditional attribute values with the [`...` attribute template tag](attribute-tag.md): ``` django
diff --git a/docs/img/favicon-white.png b/docs/img/favicon-white.png new file mode 100644 index 0000000..8ca2f4a Binary files /dev/null and b/docs/img/favicon-white.png differ diff --git a/docs/img/favicon.ico b/docs/img/favicon.ico new file mode 100644 index 0000000..26035ac Binary files /dev/null and b/docs/img/favicon.ico differ diff --git a/docs/img/favicon.png b/docs/img/favicon.png new file mode 120000 index 0000000..6929a05 --- /dev/null +++ b/docs/img/favicon.png @@ -0,0 +1 @@ +../../demosite/demo/static/favicon.png \ No newline at end of file diff --git a/docs/img/logo.svg b/docs/img/logo.svg new file mode 120000 index 0000000..384da31 --- /dev/null +++ b/docs/img/logo.svg @@ -0,0 +1 @@ +../../demosite/demo/static/logo.svg \ No newline at end of file diff --git a/docs/include-js-css.md b/docs/include-js-css.md index 58bee9d..d4daff7 100644 --- a/docs/include-js-css.md +++ b/docs/include-js-css.md @@ -1,4 +1,6 @@ -Title: Including Tetra CSS and JS +--- +title: Including Tetra CSS and JS +--- # Including Tetra CSS and JS diff --git a/docs/introduction.md b/docs/index.md similarity index 90% rename from docs/introduction.md rename to docs/index.md index 40ff430..dded517 100644 --- a/docs/introduction.md +++ b/docs/index.md @@ -1,7 +1,11 @@ -Title: Introduction +--- +title: Introduction +--- # Introduction +![Logo](img/logo.svg) + Tetra is a full stack component framework for [Django](https://docs.djangoproject.com) using [Alpine.js](https://alpinejs.dev), bridging the gap between your server logic and front end presentation. It is built on a couple of key principles: - Proximity of related concerns is as important as separation of concerns. Whilst it is important to keep your backend logic, front end JavaScript, HTML, and styles separate, it is also incredibly useful to have related code in close proximity. @@ -23,9 +27,9 @@ Furthermore, components can expose attributes and methods as *public*, making th ## Walkthrough of a simple "To Do App" -To introduce the main aspects of Tetra we will walkthrough the code implementing the [To Do App demo](/#examples) on the homepage. +To introduce the main aspects of Tetra we will walkthrough the code implementing the [To Do App demo](#examples) on the homepage. -*If you haven't used Django before you should follow their [tutorial](https://docs.djangoproject.com/en/4.0/intro/tutorial01/) before coming back here.* +*If you haven't used Django before you should follow their [tutorial](https://docs.djangoproject.com/en/4.2/intro/tutorial01/) before coming back here.* First, we need a Django "model" for saving our 'to do' items: @@ -40,7 +44,7 @@ class ToDo(models.Model): done = models.BooleanField(default=False) ``` -Assuming we have [installed and setup](install) Tetra, next we create a `components.py` file to contain our components. Every component belongs to a "Library", and for this simple app we just need one named `default`. +Assuming we have [installed and setup](install.md) Tetra, next we create a `components.py` file to contain our components. Every component belongs to a "Library", and for this simple app we just need one named `default`. ``` python # components.py @@ -110,7 +114,7 @@ Then there is the template; this uses the standard Django template language. You ### `ToDoItem` Component -Next, we create a `ToDoItem` component. As we have previously seen, there are public attributes to hold the the `title` and `done` status of the item. The load method takes a `ToDo` model instance (passed to it in the template above), then saves it as a private attribute on the component, and finally sets the `title` and `done` public attributes. +Next, we create a `ToDoItem` component. As we have previously seen, there are public attributes to hold the `title` and `done` status of the item. The load method takes a `ToDo` model instance (passed to it in the template above), then saves it as a private attribute on the component, and finally sets the `title` and `done` public attributes. ``` python @default.register @@ -214,7 +218,8 @@ Next, we define some CSS styles for the component as the multiline Python string ### Including the "to do" list in a page -Finally, we include our `to_do_list` component into a pages template using the `@` component tag. As we are doing this outside of a Tetra component we need to explicitly load the Tetra template tags with `{% load tetra %}`. +Finally, we include our `to_do_list` component into a pages template using the `@` component tag. +As we are doing this outside of a Tetra component we need to explicitly load the Tetra template tags with `{% load tetra %}`. ``` django {# index.html #} @@ -223,8 +228,9 @@ Finally, we include our `to_do_list` component into a pages template using the ` {% @ to_do_list / %} ``` - To get started, follow the [install instructions](install). + To get started, follow the [install instructions](install.md). + +!!! note + Tetra is still early in its development, and we can make no promises about API stability at this stage. -> **Note:** Tetra is still early in its development, and we can make no promises about API stability at this stage. -> -> The intention is to stabilise the API prior to a V1 release this summer, as well as implementing some additional functionality. \ No newline at end of file + The intention is to stabilise the API prior to a v1.0 release, as well as implementing some additional functionality. \ No newline at end of file diff --git a/docs/install.md b/docs/install.md index b3a7c13..607d4c5 100644 --- a/docs/install.md +++ b/docs/install.md @@ -1,16 +1,19 @@ -Title: Installation +--- +title: Installation +--- # Installation -As a component framework for Django, Tetra requires that you have a Django project setup before installing. [Follow the Django introduction tutorial](https://docs.djangoproject.com/en/4.0/intro/tutorial01/). +As a component framework for Django, Tetra requires that you have a Django project setup before installing. [Follow the Django introduction tutorial](https://docs.djangoproject.com/en/4.2/intro/tutorial01/). -Once ready install Tetra from PyPi: +Once ready, install Tetra from PyPi: ``` -$ pip install tetraframework +$ pip install tetra ``` -> **Note:** As Tetra is still being developed it has only been tested with Python 3.9 and 3.10, we intend to support at least 3.8 before a V1 release. +!!! note + As Tetra is still being developed it has only been tested with Python 3.9 and 3.10, we intend to support at least 3.8 before a V1 release. ## Initial configuration diff --git a/docs/magic-static.md b/docs/magic-static.md index 650e56c..23550be 100644 --- a/docs/magic-static.md +++ b/docs/magic-static.md @@ -1,6 +1,8 @@ -Title: $static +--- +title: Alpine.js Magic +--- # Alpine.js Magic: `$static` -The `$static(path)` Alpine.js magic is the client side equivalent of the [Django `static` template tag](https://docs.djangoproject.com/en/4.0/ref/templates/builtins/#static). It takes a path in string form relative to your static root and returns the correct path to the file, whether it is on the same host, or on a completely different domain. +The `$static(path)` Alpine.js magic is the client side equivalent of the [Django `static` template tag](https://docs.djangoproject.com/en/4.2/ref/templates/builtins/#static). It takes a path in string form relative to your static root and returns the correct path to the file, whether it is on the same host, or on a completely different domain. diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 0000000..c626a6c --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,5 @@ +mkdocs +mkdocs-material +pymdown-extensions +pygments +mkdocstrings[python] \ No newline at end of file diff --git a/docs/state-security.md b/docs/state-security.md index 3557f9f..55cc9ed 100644 --- a/docs/state-security.md +++ b/docs/state-security.md @@ -1,4 +1,6 @@ -Title: Saved Server State and Security +--- +title: Saved Server State and Security +--- # Saved Server State and Security diff --git a/docs/structure.yaml b/docs/structure.yaml deleted file mode 100644 index e981521..0000000 --- a/docs/structure.yaml +++ /dev/null @@ -1,17 +0,0 @@ -- introduction: Introduction -- install: Installation -- Components: - - component-libraries: Component Libraries - - components: Components - - basic-components: Basic Components -- Template Tags: - - component-tag: "`@` Component" - - attribute-tag: "`...` Attribute" - - include-js-css: "`tetra_styles` & `tetra_scripts`" -- Template Filters: - - if-else-filters: "`if` & `else`" -- Alpine.js Magics: - - magic-static: "`$static`" -- state-security: State Security -- contribute: Contributing -- changelog: Change Log \ No newline at end of file diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..02b2432 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,20 @@ +--- +title: Testing +--- + +# Testing + +Testing Tetra is done using pytest. Make sure you have npm (or yarn etc.) installed, Tetra needs esbuild for building +the frontend components before testing. + +```bash +python -m pip install .[dev] +cd tests +npm install +``` + +Within the `tests` directory, just call `pytest` to test Tetra components. + +```bash +pytest +``` \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..274e6f9 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,40 @@ +site_name: Tetra framework +site_url: https://tetraframework.readthedocs.org +theme: + name: material + logo: img/favicon-white.png + favicon: img/favicon.png + palette: + primary: grey + accent: blue + features: + - navigation.footer + - navigation.top + - navigation.instant + - navigation.expand +# - navigation.tabs +# - navigation.tabs.sticky +markdown_extensions: + - admonition + - pymdownx.highlight: + anchor_linenums: true + - pymdownx.superfences + +nav: + - index.md + - install.md + - Components: + - component-libraries.md + - components.md + - basic-components.md + - component-inheritance.md + - Template: + - component-tag.md + - attribute-tag.md + - include-js-css.md + - if-else-filters.md + - state-security.md + - magic-static.md + - Development: + - contribute.md + - changelog.md diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d8f42bf --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,65 @@ +[build-system] +requires = ["setuptools>=61.0.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "tetra" +dynamic = ["version"] +description = "Full stack component framework for Django using Alpine.js" +authors = [ + { name = "Sam Willis", email="sam.willis@gmail.com"}, + { name = "Christian González", email = "christian.gonzalez@nerdocs.at" } +] +license = {file = "LICENSE"} +readme = "README.md" +keywords = ["python", "django", "framework", "components"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", +] +dependencies = [ + "cryptography>=37.0.1", + "Django>=3.2.0", + "python-dateutil>=2.8.2", + "django-environ", +] +requires-python = ">=3.8" + +[project.urls] +Homepage = "https://tetraframework.com" +Documentation = "https://tetra.readthedocs.io" +Repository = "https://github.com/tetra-framework/tetra" + +[project.optional-dependencies] +dev = [ + "build", + "twine", + "pytest", + "pytest-django", + "pre-commit", + "black", + "python-dateutil>=2.8.2", + "beautifulsoup4", + "tetra[demo]", # include all the demo packages too +] +demo = [ + "PyYAML>=6.0", + "markdown>=3.3.7", + "gunicorn", + "django-environ", + "whitenoise>=6.6.0", + "PyYAML>=6.0", + "markdown>=3.3.7", + "sourcetypes>=0.0.4", +] + +[tool.setuptools.dynamic] +version = {attr = "tetra.__version__"} + +[tool.setuptools.packages.find] +exclude = ["docs", "tests", "demosite"] + +#[tool.pytest.ini_options] +#DJANGO_SETTINGS_MODULE="tests.settings" \ No newline at end of file diff --git a/setup.py b/setup.py deleted file mode 100644 index 9f716b7..0000000 --- a/setup.py +++ /dev/null @@ -1,29 +0,0 @@ -import setuptools -from tetra import __version__ - -with open("README.md", "r") as fh: - long_description = fh.read() - -setuptools.setup( - name="tetraframework", - version=__version__, - url="https://www.tetraframework.com", - author="Sam Willis", - description="Full stack component framework for Django using Alpine.js", - long_description=long_description, - long_description_content_type="text/markdown", - include_package_data=True, - packages=setuptools.find_packages(exclude="demosite"), - classifiers=[ - "Programming Language :: Python :: 3", - "License :: OSI Approved :: MIT License", - "Operating System :: OS Independent", - ], - python_requires=">=3.7", - install_requires=[ - "cryptography>=37.0.1", - "Django>=3.2.0", - "python-dateutil>=2.8.2", - "sourcetypes>=0.0.4", - ], -) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..47b2ec4 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,66 @@ +import pytest +from pathlib import Path + +from bs4 import BeautifulSoup +from django.conf import settings +from django.core.management import call_command + +BASE_DIR = Path(__file__).resolve().parent + + +@pytest.fixture(scope="session", autouse=True) +def setup_django_environment(): + # Call your `tetrabuild` command before running tests - to make sure the Js + # scripts and CSS files are built. + call_command("tetrabuild") + + +def pytest_configure(): + settings.configure( + BASE_DIR=BASE_DIR, + SECRET_KEY="django-insecure1234567890", + ROOT_URLCONF="tests.urls", + INSTALLED_APPS=[ + "tetra", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.staticfiles", + "tests.main", + ], + MIDDLEWARE=[ + "django.middleware.security.SecurityMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + "tetra.middleware.TetraMiddleware", + ], + DATABASES={ + "default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"} + }, + TEMPLATES=[ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [BASE_DIR / "templates"], + "APP_DIRS": True, + }, + ], + STATIC_URL="/static/", + STATIC_ROOT=BASE_DIR / "staticfiles", + DEBUG=True, + STORAGES={ + "staticfiles": { + "BACKEND": "whitenoise.storage.CompressedStaticFilesStorage", + }, + }, + ) + + +def extract_component(html: str | bytes): + """Helper to extract the `div#component` content from the given HTML. + Also cuts out ALL newlines from the output. + """ + return BeautifulSoup(html).html.body.find(id="component").text.replace("\n", "") diff --git a/tests/main/__init__.py b/tests/main/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/main/apps.py b/tests/main/apps.py new file mode 100644 index 0000000..fda6629 --- /dev/null +++ b/tests/main/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class MainConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "tests.main" diff --git a/tests/main/components.py b/tests/main/components.py new file mode 100644 index 0000000..97a3cc7 --- /dev/null +++ b/tests/main/components.py @@ -0,0 +1,48 @@ +from tetra import BasicComponent, Component, Library +from sourcetypes import django_html, css + +default = Library() + + +@default.register +class SimpleBasicComponent(BasicComponent): + template: django_html = "
foo
" + + +@default.register +class SimpleBasicComponentWithCSS(BasicComponent): + template: django_html = "
bar
" + style: css = ".text-red { color: red; }" + + +@default.register +class SimpleComponentWithDefaultBlock(BasicComponent): + template: django_html = ( + "
{% block default %}{% endblock %}
" + ) + + +@default.register +class SimpleComponentWithNamedBlock(BasicComponent): + template: django_html = "
{% block foo %}{% endblock %}
" + + +@default.register +class SimpleComponentWithNamedBlockWithContent(BasicComponent): + template: django_html = "
{% block foo %}foo{% endblock %}
" + + +@default.register +class SimpleComponentWithConditionalBlock(BasicComponent): + template: django_html = """ +
+{% if blocks.foo %}BEFORE{% block foo %}content{% endblock %}AFTER{% endif %}always +
+""" + + +@default.register +class SimpleComponentWith2Blocks(BasicComponent): + template: django_html = """ +
{% block default %}default{% endblock %}{% block foo %}foo{% endblock %}
+""" diff --git a/tests/main/helpers.py b/tests/main/helpers.py new file mode 100644 index 0000000..8fcb908 --- /dev/null +++ b/tests/main/helpers.py @@ -0,0 +1,16 @@ +from django.template import Template, Context + + +def render_component(request, component_string): + """Helper function to return a full html document with loaded Tetra stuff.""" + context = Context() + context.request = request + return Template( + "{% load tetra %}" + "" + "{% tetra_styles %}" + "{% tetra_scripts include_alpine=True %}" + "" + f"{component_string}" + "" + ).render(context) diff --git a/tests/main/migrations/__init__.py b/tests/main/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/main/static/.gitignore b/tests/main/static/.gitignore new file mode 100644 index 0000000..22d22bb --- /dev/null +++ b/tests/main/static/.gitignore @@ -0,0 +1,2 @@ +# dynamically generated files of Tetra tests +main/tetra/default \ No newline at end of file diff --git a/tests/main/templates/base.html b/tests/main/templates/base.html new file mode 100644 index 0000000..220a501 --- /dev/null +++ b/tests/main/templates/base.html @@ -0,0 +1,11 @@ +{% load tetra %} + + + +{# Even in a test environment, we need the full tetra stuff for components to work properly.#} + {% tetra_styles %} + {% tetra_scripts include_alpine=True %} + Tetra test project + +{% block content %}{% endblock %} + \ No newline at end of file diff --git a/tests/main/templates/basic_component.html b/tests/main/templates/basic_component.html new file mode 100644 index 0000000..c5368bd --- /dev/null +++ b/tests/main/templates/basic_component.html @@ -0,0 +1,5 @@ +{% extends 'base.html' %} +{% load tetra %} +{% block content %} + {% @ main.default.simple_basic_component / %} +{% endblock %} diff --git a/tests/main/views.py b/tests/main/views.py new file mode 100644 index 0000000..3fd6432 --- /dev/null +++ b/tests/main/views.py @@ -0,0 +1,11 @@ +from django.http import HttpResponse + +from tests.main.helpers import render_component + + +def simple_basic_component_with_css(request): + return HttpResponse( + render_component( + request, "{% @ main.default.simple_basic_component_with_css / %}" + ) + ) diff --git a/tests/package-lock.json b/tests/package-lock.json new file mode 100644 index 0000000..f6bc331 --- /dev/null +++ b/tests/package-lock.json @@ -0,0 +1,66 @@ +{ + "name": "tetra-tests", + "version": "0.0.6", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "tetra-tests", + "version": "0.0.6", + "license": "MIT", + "dependencies": { + "esbuild": "^0.14.54" + } + }, + "node_modules/esbuild": { + "version": "0.14.54", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.14.54.tgz", + "integrity": "sha512-Cy9llcy8DvET5uznocPyqL3BFRrFXSVqbgpMJ9Wz8oVjZlh/zUSNbPRbov0VX7VxN2JH1Oa0uNxZ7eLRb62pJA==", + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/linux-loong64": "0.14.54", + "esbuild-android-64": "0.14.54", + "esbuild-android-arm64": "0.14.54", + "esbuild-darwin-64": "0.14.54", + "esbuild-darwin-arm64": "0.14.54", + "esbuild-freebsd-64": "0.14.54", + "esbuild-freebsd-arm64": "0.14.54", + "esbuild-linux-32": "0.14.54", + "esbuild-linux-64": "0.14.54", + "esbuild-linux-arm": "0.14.54", + "esbuild-linux-arm64": "0.14.54", + "esbuild-linux-mips64le": "0.14.54", + "esbuild-linux-ppc64le": "0.14.54", + "esbuild-linux-riscv64": "0.14.54", + "esbuild-linux-s390x": "0.14.54", + "esbuild-netbsd-64": "0.14.54", + "esbuild-openbsd-64": "0.14.54", + "esbuild-sunos-64": "0.14.54", + "esbuild-windows-32": "0.14.54", + "esbuild-windows-64": "0.14.54", + "esbuild-windows-arm64": "0.14.54" + } + }, + "node_modules/esbuild-linux-64": { + "version": "0.14.54", + "resolved": "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.14.54.tgz", + "integrity": "sha512-EgjAgH5HwTbtNsTqQOXWApBaPVdDn7XcK+/PtJwZLT1UmpLoznPd8c5CxqsH2dQK3j05YsB3L17T8vE7cp4cCg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + } + } +} diff --git a/tests/package.json b/tests/package.json new file mode 100644 index 0000000..bd1b183 --- /dev/null +++ b/tests/package.json @@ -0,0 +1,15 @@ +{ + "name": "tetra-tests", + "private": true, + "version": "0.0.6", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "dependencies": { + "esbuild": "^0.14.54" + }, + "author": "Christian González ", + "license": "MIT" +} diff --git a/tests/test_component_blocks.py b/tests/test_component_blocks.py new file mode 100644 index 0000000..5678a9d --- /dev/null +++ b/tests/test_component_blocks.py @@ -0,0 +1,145 @@ +from tests.conftest import extract_component +from tests.main.helpers import render_component + + +def test_component_with_default_block(request): + """Tests a simple component with default block""" + content = render_component( + request, + "{% @ main.default.simple_component_with_default_block %}content{% /@ %}", + ) + assert extract_component(content) == "content" + + +def test_component_with_named_block(request): + """Tests a simple component with empty default block (unfilled)""" + content = render_component( + request, + "{% @ main.default.simple_component_with_named_block %}{% /@ %}", + ) + assert extract_component(content) == "" + + +def test_component_with_named_block_and_content(request): + """Tests a simple component with "foo" block""" + content = render_component( + request, + "{% @ main.default.simple_component_with_named_block %}" + "{% block foo %}foo{% endblock %}" + "{% /@ %}", + ) + assert extract_component(content) == "foo" + + +def test_component_with_named_block_and_default_content(request): + """Tests a simple component with "foo" block and default content in it""" + content = render_component( + request, + "{% @ main.default.simple_component_with_named_block_with_content %}" + "{% /@ %}", + ) + assert extract_component(content) == "foo" + + +def test_component_with_notexisting_block_and_content(request): + """Tests a simple component with notexisting block filled. Must be ignored.""" + content = render_component( + request, + "{% @ main.default.simple_component_with_named_block %}" + "{% block notexisting %}foo{% endblock %}" + "{% /@ %}", + ) + assert extract_component(content) == "" + + +def test_component_with_named_block_empty(request): + """Tests a simple component named""" + content = render_component( + request, + "{% @ main.default.simple_component_with_named_block %}" + "{% block foo %}{% endblock %}" + "{% /@ %}", + ) + assert extract_component(content) == "" + + +# FIXME: this test does not work correctly +# def test_component_with_named_block_and_content_outside_block_ignored(request): +# """Tests a simple component with content outside of blocks. Must not be rendered.""" +# content = render_component( +# request, +# "{% @ main.default.simple_component_with_named_block %}" +# "{% block foo %}inside{% endblock %}" +# "this text must not be rendered, as there is no default block in the component." +# "{% /@ %}", +# ) +# assert extract_component(content) == "inside" + + +def test_component_with_2_blocks_unfilled(request): + """Tests a simple component with `foo` and `default` blocks unfilled. Default + block contains some default content""" + content = render_component( + request, + "{% @ main.default.simple_component_with2_blocks %}" + "{% block foo %}{% endblock %}" + "{% /@ %}", + ) + assert extract_component(content) == "default" + + +def test_component_with_2_blocks_partly_filled(request): + """Tests a simple component with 2 blocks partly filled""" + content = render_component( + request, + "{% @ main.default.simple_component_with2_blocks %}" + "{% block foo %}bar{% endblock %}" + "{% /@ %}", + ) + assert extract_component(content) == "defaultbar" + + +def test_component_with_block_and_default_content_overridden(request): + """Tests a simple component overridden default content""" + content = render_component( + request, + "{% @ main.default.simple_component_with_named_block %}" + "{% block foo %}overridden{% endblock %}" + "{% /@ %}", + ) + assert extract_component(content) == "overridden" + + +def test_component_with_conditional_block_empty(request): + """Tests a simple component with conditional block that is not rendered, + as it is empty + """ + content = render_component( + request, "{% @ main.default.simple_component_with_conditional_block / %}" + ) + assert extract_component(content) == "always" + + +def test_component_with_conditional_block_filled_empty(request): + """Tests a simple component with default content, that is overridden with empty + block. + """ + content = render_component( + request, + "{% @ main.default.simple_component_with_conditional_block %}" + "{% block foo %}{% endblock %}" + "{% /@ %}", + ) + assert extract_component(content) == "BEFOREAFTERalways" + + +def test_component_with_conditional_block_filled(request): + """Tests a simple component with conditional block, filled, with mixed text from + component and block overrides.""" + content = render_component( + request, + "{% @ main.default.simple_component_with_conditional_block %}" + "{% block foo %}foo{% endblock %}" + "{% /@ %}", + ) + assert extract_component(content) == "BEFOREfooAFTERalways" diff --git a/tests/test_component_tags.py b/tests/test_component_tags.py new file mode 100644 index 0000000..26aa9a4 --- /dev/null +++ b/tests/test_component_tags.py @@ -0,0 +1,55 @@ +from bs4 import BeautifulSoup +from django.urls import reverse +from django.template.exceptions import TemplateSyntaxError + +from tests.conftest import extract_component +from tests.main.helpers import render_component +import pytest + + +def test_basic_component(request): + """Tests a simple component with / end""" + content = render_component(request, "{% @ main.default.simple_basic_component / %}") + assert extract_component(content) == "foo" + + +def test_basic_component_with_end_tag(request): + """Tests a simple component with /@ end tag""" + content = render_component( + request, "{% @ main.default.simple_basic_component %}{% /@ %}" + ) + assert extract_component(content) == "foo" + + +def test_basic_component_with_end_tag_and_name(request): + """Tests a simple component with `/@ ` end tag""" + content = render_component( + request, + "{% @ main.default.simple_basic_component %}{% /@ simple_basic_component%}", + ) + assert extract_component(content) == "foo" + + +def test_basic_component_with_missing_end_tag(request): + """Tests a simple component without end tag - must produce TemplateSyntaxError""" + with pytest.raises(TemplateSyntaxError): + content = render_component( + request, + "{% @ main.default.simple_basic_component %}", + ) + + +def test_css_component(client): + """Tests a component with CSS file""" + response = client.get(reverse("simple_basic_component_with_css")) + assert response.status_code == 200 + soup = BeautifulSoup(response.content, "html.parser") + # it should be the only link in the header... TODO: make that more fool-proof + link = soup.head.link["href"] + assert "main_default" in link + assert link is not None + response = client.get(link) + # FIXME: does not work yet. staticfiles problem? Should run in DEBUG mode without + # problem... + # assert response.status_code == 200 + # assert b".text-red { color: red; }" in response.content diff --git a/tests/urls.py b/tests/urls.py new file mode 100644 index 0000000..fac0fd5 --- /dev/null +++ b/tests/urls.py @@ -0,0 +1,14 @@ +from django.conf.urls.static import static +from django.urls import path, include +from django.conf import settings +from tests.main.views import simple_basic_component_with_css + + +urlpatterns = [ + path("__tetra__", include("tetra.urls")), + path( + "simple_basic_component_with_css", + simple_basic_component_with_css, + name="simple_basic_component_with_css", + ), +] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) diff --git a/tetra/__init__.py b/tetra/__init__.py index dcf54f4..fe6a1c1 100644 --- a/tetra/__init__.py +++ b/tetra/__init__.py @@ -1,5 +1,6 @@ +"""Full stack component framework for Django using Alpine.js""" from .components import BasicComponent, Component, public from .library import Library -__version__ = "0.0.5" +__version__ = "0.1.1" __version_info__ = tuple([int(num) for num in __version__.split(".")]) diff --git a/tetra/component_register.py b/tetra/component_register.py index 905e72d..d435af6 100644 --- a/tetra/component_register.py +++ b/tetra/component_register.py @@ -1,13 +1,15 @@ +import logging + from django.apps import apps from importlib import import_module from django.template import Template import inspect from collections import defaultdict -from .components import Component from .components.base import InlineTemplate, ComponentNotFound from .library import Library, ComponentLibraryException +logger = logging.getLogger(__file__) libraries = defaultdict(dict) find_libraries_done = False @@ -16,17 +18,26 @@ def find_component_libraries(): + """Finds libraries in component modules of all installed django apps.""" global libraries global find_libraries_done if find_libraries_done: return for component_module_name in component_module_names: for app in apps.get_app_configs(): + module_name = f"{app.module.__name__}.{component_module_name}" try: - component_module = import_module( - f"{app.module.__name__}.{component_module_name}" - ) - except ModuleNotFoundError: + component_module = import_module(module_name) + except ModuleNotFoundError as e: + # this is a bit risky to compare the error msg's output, but it's the + # only way to check if the import error is due to a direct import + # error of the module or if the import was ok, but the imported + # module itself raises an exception. + if e.msg != f"No module named '{module_name}'": + logger.error(f"Error importing: {module_name}: {e}") + continue + except Exception as e: + logger.error(e) continue for name, member in inspect.getmembers(component_module): if isinstance(member, Library): @@ -58,6 +69,8 @@ def resolve_component(context, name): '"[app_name.][library_name.]component_name".' ) + # if component is called with 2 parts, we need a current_app context to find the + # component if ( isinstance(template, InlineTemplate) and template.origin diff --git a/tetra/components/base.py b/tetra/components/base.py index 06cea47..0f20f6c 100644 --- a/tetra/components/base.py +++ b/tetra/components/base.py @@ -9,7 +9,8 @@ from functools import wraps from threading import local -from django.template.loader import render_to_string +from django.template.base import Template +from django.template.loader import render_to_string, get_template from django.template import RequestContext, TemplateSyntaxError from django.template.loader_tags import BLOCK_CONTEXT_KEY, BlockContext, BlockNode from django.utils.safestring import mark_safe @@ -36,41 +37,62 @@ class ComponentNotFound(ComponentException): pass -def make_template(cls): +def make_template(cls) -> Template: + """Create a template from a component class. + + Uses either the `cls.template` attribute as inline template string, + or `cls.template_name` as template file source. If both are defined, 'template' + overrides 'template_name'. + """ from ..templatetags.tetra import get_nodes_by_type_deep - making_lazy_after_exception = False - filename, line = cls.get_template_source_location() - origin = InlineOrigin( - name=f"{filename}:{cls.__name__}.template", - template_name=filename, - start_line=line, - component=cls, - ) - try: - template = InlineTemplate( - "{% load tetra %}" + cls.template, - origin=origin, + # if only "template" is defined, use it as inline template string. + if hasattr(cls, "template"): + making_lazy_after_exception = False + filename, line = cls.get_template_source_location() + origin = InlineOrigin( + name=f"{filename}:{cls.__name__}.template", + template_name=filename, + start_line=line, + component=cls, ) - except TemplateSyntaxError as e: - # By default we want to compile templates during python compile time, however - # the template exceptions are much better when raised at runtime as it shows - # a nice stack trace in the browser. We therefore create a "Lazy" template - # after a compile error that will run in the browser when testing. - # TODO: turn this off when DEBUG=False - making_lazy_after_exception = True - template = SimpleLazyObject( - lambda: InlineTemplate( + try: + template = InlineTemplate( "{% load tetra %}" + cls.template, origin=origin, ) + except TemplateSyntaxError as e: + # By default we want to compile templates during python compile time, however + # the template exceptions are much better when raised at runtime as it shows + # a nice stack trace in the browser. We therefore create a "Lazy" template + # after a compile error that will run in the browser when testing. + # TODO: turn this off when DEBUG=False + from django.conf import settings + + if settings.DEBUG: + making_lazy_after_exception = True + template = SimpleLazyObject( + lambda: InlineTemplate( + "{% load tetra %}" + cls.template, + origin=origin, + ) + ) + if not making_lazy_after_exception: + for i, block_node in enumerate( + get_nodes_by_type_deep(template.nodelist, BlockNode) + ): + if not getattr(block_node, "origin", None): + block_node.origin = origin + + # if "template_name" is defined, use it as template file source. + elif hasattr(cls, "template_name"): + template = get_template(cls.template_name).template + else: + raise ComponentException( + f"You must provide either a `template_name` or a `template` in Component" + f" {cls.__name__}." ) - if not making_lazy_after_exception: - for i, block_node in enumerate( - get_nodes_by_type_deep(template.nodelist, BlockNode) - ): - if not getattr(block_node, "origin", None): - block_node.origin = origin + return template @@ -78,7 +100,7 @@ class BasicComponentMetaClass(type): def __new__(mcls, name, bases, attrs): newcls = super().__new__(mcls, name, bases, attrs) newcls._name = camel_case_to_underscore(newcls.__name__) - if hasattr(newcls, "template"): + if hasattr(newcls, "template") or hasattr(newcls, "template_name"): newcls._template = make_template(newcls) return newcls @@ -89,7 +111,7 @@ class RenderData(Enum): UPDATE = 2 -class BasicComponent(object, metaclass=BasicComponentMetaClass): +class BasicComponent(metaclass=BasicComponentMetaClass): style: Optional[str] = None _name = None _library = None diff --git a/tetra/middleware.py b/tetra/middleware.py index 890fe0f..700f457 100644 --- a/tetra/middleware.py +++ b/tetra/middleware.py @@ -30,17 +30,19 @@ def __call__(self, request): if hasattr(request, "tetra_components_used") and request.tetra_components_used: if not hasattr(request, "tetra_scripts_placeholder_string"): raise TetraMiddlewareException( - "{% tetra_scripts %} tag required to be used when using Tetra components." + "The {% tetra_scripts %} tag is required to be placed in the " + "page's tag when using Tetra components." ) if not hasattr(request, "tetra_styles_placeholder_string"): raise TetraMiddlewareException( - "{% tetra_styles %} tag required to be place in the page when using Tetra components." + "The {% tetra_styles %} tag is required to be placed in the page's " + " tag when using Tetra components." ) - if not request.tetra_scripts_placeholder_string in response.content: + if request.tetra_scripts_placeholder_string not in response.content: raise TetraMiddlewareException( "Placeholder from {% tetra_scripts %} not found." ) - if not request.tetra_styles_placeholder_string in response.content: + if request.tetra_styles_placeholder_string not in response.content: raise TetraMiddlewareException( "Placeholder from {% tetra_styles %} not found." ) diff --git a/tetra/static/tetra/js/alpinejs.cdn.min.js b/tetra/static/tetra/js/alpinejs.cdn.min.js index 42ab077..cc6050c 100644 --- a/tetra/static/tetra/js/alpinejs.cdn.min.js +++ b/tetra/static/tetra/js/alpinejs.cdn.min.js @@ -1,5 +1,5 @@ -(()=>{var We=!1,Ge=!1,j=[];function Nt(e){nn(e)}function nn(e){j.includes(e)||j.push(e),on()}function he(e){let t=j.indexOf(e);t!==-1&&j.splice(t,1)}function on(){!Ge&&!We&&(We=!0,queueMicrotask(sn))}function sn(){We=!1,Ge=!0;for(let e=0;ee.effect(t,{scheduler:r=>{Je?Nt(r):r()}}),Ye=e.raw}function Ze(e){K=e}function Dt(e){let t=()=>{};return[n=>{let i=K(n);return e._x_effects||(e._x_effects=new Set,e._x_runEffects=()=>{e._x_effects.forEach(o=>o())}),e._x_effects.add(i),t=()=>{i!==void 0&&(e._x_effects.delete(i),Y(i))},i},()=>{t()}]}var $t=[],Lt=[],Ft=[];function jt(e){Ft.push(e)}function _e(e,t){typeof t=="function"?(e._x_cleanups||(e._x_cleanups=[]),e._x_cleanups.push(t)):(t=e,Lt.push(t))}function Kt(e){$t.push(e)}function Bt(e,t,r){e._x_attributeCleanups||(e._x_attributeCleanups={}),e._x_attributeCleanups[t]||(e._x_attributeCleanups[t]=[]),e._x_attributeCleanups[t].push(r)}function Qe(e,t){!e._x_attributeCleanups||Object.entries(e._x_attributeCleanups).forEach(([r,n])=>{(t===void 0||t.includes(r))&&(n.forEach(i=>i()),delete e._x_attributeCleanups[r])})}var et=new MutationObserver(Xe),tt=!1;function rt(){et.observe(document,{subtree:!0,childList:!0,attributes:!0,attributeOldValue:!0}),tt=!0}function cn(){an(),et.disconnect(),tt=!1}var ee=[],nt=!1;function an(){ee=ee.concat(et.takeRecords()),ee.length&&!nt&&(nt=!0,queueMicrotask(()=>{ln(),nt=!1}))}function ln(){Xe(ee),ee.length=0}function m(e){if(!tt)return e();cn();let t=e();return rt(),t}var it=!1,ge=[];function zt(){it=!0}function Vt(){it=!1,Xe(ge),ge=[]}function Xe(e){if(it){ge=ge.concat(e);return}let t=[],r=[],n=new Map,i=new Map;for(let o=0;os.nodeType===1&&t.push(s)),e[o].removedNodes.forEach(s=>s.nodeType===1&&r.push(s))),e[o].type==="attributes")){let s=e[o].target,a=e[o].attributeName,c=e[o].oldValue,l=()=>{n.has(s)||n.set(s,[]),n.get(s).push({name:a,value:s.getAttribute(a)})},u=()=>{i.has(s)||i.set(s,[]),i.get(s).push(a)};s.hasAttribute(a)&&c===null?l():s.hasAttribute(a)?(u(),l()):u()}i.forEach((o,s)=>{Qe(s,o)}),n.forEach((o,s)=>{$t.forEach(a=>a(s,o))});for(let o of r)if(!t.includes(o)&&(Lt.forEach(s=>s(o)),o._x_cleanups))for(;o._x_cleanups.length;)o._x_cleanups.pop()();t.forEach(o=>{o._x_ignoreSelf=!0,o._x_ignore=!0});for(let o of t)r.includes(o)||!o.isConnected||(delete o._x_ignoreSelf,delete o._x_ignore,Ft.forEach(s=>s(o)),o._x_ignore=!0,o._x_ignoreSelf=!0);t.forEach(o=>{delete o._x_ignoreSelf,delete o._x_ignore}),t=null,r=null,n=null,i=null}function xe(e){return P(N(e))}function C(e,t,r){return e._x_dataStack=[t,...N(r||e)],()=>{e._x_dataStack=e._x_dataStack.filter(n=>n!==t)}}function ot(e,t){let r=e._x_dataStack[0];Object.entries(t).forEach(([n,i])=>{r[n]=i})}function N(e){return e._x_dataStack?e._x_dataStack:typeof ShadowRoot=="function"&&e instanceof ShadowRoot?N(e.host):e.parentNode?N(e.parentNode):[]}function P(e){let t=new Proxy({},{ownKeys:()=>Array.from(new Set(e.flatMap(r=>Object.keys(r)))),has:(r,n)=>e.some(i=>i.hasOwnProperty(n)),get:(r,n)=>(e.find(i=>{if(i.hasOwnProperty(n)){let o=Object.getOwnPropertyDescriptor(i,n);if(o.get&&o.get._x_alreadyBound||o.set&&o.set._x_alreadyBound)return!0;if((o.get||o.set)&&o.enumerable){let s=o.get,a=o.set,c=o;s=s&&s.bind(t),a=a&&a.bind(t),s&&(s._x_alreadyBound=!0),a&&(a._x_alreadyBound=!0),Object.defineProperty(i,n,{...c,get:s,set:a})}return!0}return!1})||{})[n],set:(r,n,i)=>{let o=e.find(s=>s.hasOwnProperty(n));return o?o[n]=i:e[e.length-1][n]=i,!0}});return t}function ye(e){let t=n=>typeof n=="object"&&!Array.isArray(n)&&n!==null,r=(n,i="")=>{Object.entries(Object.getOwnPropertyDescriptors(n)).forEach(([o,{value:s,enumerable:a}])=>{if(a===!1||s===void 0)return;let c=i===""?o:`${i}.${o}`;typeof s=="object"&&s!==null&&s._x_interceptor?n[o]=s.initialize(e,c,o):t(s)&&s!==n&&!(s instanceof Element)&&r(s,c)})};return r(e)}function be(e,t=()=>{}){let r={initialValue:void 0,_x_interceptor:!0,initialize(n,i,o){return e(this.initialValue,()=>un(n,i),s=>st(n,i,s),i,o)}};return t(r),n=>{if(typeof n=="object"&&n!==null&&n._x_interceptor){let i=r.initialize.bind(r);r.initialize=(o,s,a)=>{let c=n.initialize(o,s,a);return r.initialValue=c,i(o,s,a)}}else r.initialValue=n;return r}}function un(e,t){return t.split(".").reduce((r,n)=>r[n],e)}function st(e,t,r){if(typeof t=="string"&&(t=t.split(".")),t.length===1)e[t[0]]=r;else{if(t.length===0)throw error;return e[t[0]]||(e[t[0]]={}),st(e[t[0]],t.slice(1),r)}}var Ht={};function x(e,t){Ht[e]=t}function te(e,t){return Object.entries(Ht).forEach(([r,n])=>{Object.defineProperty(e,`$${r}`,{get(){let[i,o]=at(t);return i={interceptor:be,...i},_e(t,o),n(t,i)},enumerable:!1})}),e}function qt(e,t,r,...n){try{return r(...n)}catch(i){J(i,e,t)}}function J(e,t,r=void 0){Object.assign(e,{el:t,expression:r}),console.warn(`Alpine Expression Error: ${e.message} +(()=>{var rt=!1,nt=!1,U=[],it=-1;function Vt(e){An(e)}function An(e){U.includes(e)||U.push(e),On()}function Se(e){let t=U.indexOf(e);t!==-1&&t>it&&U.splice(t,1)}function On(){!nt&&!rt&&(rt=!0,queueMicrotask(Cn))}function Cn(){rt=!1,nt=!0;for(let e=0;ee.effect(t,{scheduler:r=>{ot?Vt(r):r()}}),st=e.raw}function at(e){D=e}function Wt(e){let t=()=>{};return[n=>{let i=D(n);return e._x_effects||(e._x_effects=new Set,e._x_runEffects=()=>{e._x_effects.forEach(o=>o())}),e._x_effects.add(i),t=()=>{i!==void 0&&(e._x_effects.delete(i),L(i))},i},()=>{t()}]}function ve(e,t){let r=!0,n,i=D(()=>{let o=e();JSON.stringify(o),r?n=o:queueMicrotask(()=>{t(o,n),n=o}),r=!1});return()=>L(i)}function W(e,t,r={}){e.dispatchEvent(new CustomEvent(t,{detail:r,bubbles:!0,composed:!0,cancelable:!0}))}function C(e,t){if(typeof ShadowRoot=="function"&&e instanceof ShadowRoot){Array.from(e.children).forEach(i=>C(i,t));return}let r=!1;if(t(e,()=>r=!0),r)return;let n=e.firstElementChild;for(;n;)C(n,t,!1),n=n.nextElementSibling}function E(e,...t){console.warn(`Alpine Warning: ${e}`,...t)}var Gt=!1;function Jt(){Gt&&E("Alpine has already been initialized on this page. Calling Alpine.start() more than once can cause problems."),Gt=!0,document.body||E("Unable to initialize. Trying to load Alpine before `` is available. Did you forget to add `defer` in Alpine's `