diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 86fd96a72..b545850eb 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -6,7 +6,7 @@ repos: - id: ruff-format - id: ruff-check - - repo: git@github.com:pre-commit/pre-commit-hooks + - repo: https://github.com/pre-commit/pre-commit-hooks rev: v3.4.0 hooks: - id: no-commit-to-branch diff --git a/apps/villages/forms.py b/apps/villages/forms.py index 38c536847..11bfc1465 100644 --- a/apps/villages/forms.py +++ b/apps/villages/forms.py @@ -19,6 +19,7 @@ class VillageForm(Form): name = StringField("Village Name", [Length(2, 25)]) description = TextAreaField("Description", [Optional()]) + long_description = TextAreaField("Long Description", [Optional()]) url = StringField("URL", [URL(), Optional()]) num_attendees = IntegerField("Number of People", [Optional()]) @@ -46,6 +47,7 @@ class VillageForm(Form): def populate(self, village: Village) -> None: self.name.data = village.name self.description.data = village.description + self.long_description.data = village.long_description self.url.data = village.url requirements = village.requirements @@ -60,6 +62,7 @@ def populate_obj(self, village: Village) -> None: assert self.name.data is not None village.name = self.name.data village.description = self.description.data + village.long_description = self.long_description.data village.url = self.url.data if village.requirements is None: diff --git a/apps/villages/views.py b/apps/villages/views.py index 9dfc04757..0af280fa3 100644 --- a/apps/villages/views.py +++ b/apps/villages/views.py @@ -1,6 +1,11 @@ +import html + +import markdown +import nh3 from flask import abort, flash, redirect, render_template, request, url_for from flask.typing import ResponseValue from flask_login import current_user, login_required +from markupsafe import Markup from sqlalchemy import exists, select from main import db @@ -65,6 +70,66 @@ def main(year: int) -> ResponseValue: ) +@villages.route("//") +def view(year: int, village_id: int) -> ResponseValue: + village = load_village(year, village_id) + + return render_template( + "villages/view.html", + village=village, + village_long_description_html=render_markdown(village.long_description), + ) + + +def render_markdown(markdown_text): + """Render untrusted markdown + + This doesn't have access to any templating unlike email markdown + which is from a trusted user so is pre-processed with jinja. + """ + extensions = ["markdown.extensions.nl2br", "markdown.extensions.smarty", "tables"] + contentHtml = nh3.clean( + markdown.markdown(markdown_text, extensions=extensions), tags=(nh3.ALLOWED_TAGS - {"img"}) + ) + innerHtml = render_template("sandboxed-iframe.html", body=Markup(contentHtml)) + iFrameHtml = f'' + return Markup(iFrameHtml) + + +@villages.route("///view2") +def view2(year: int, village_id: int) -> ResponseValue: + village = load_village(year, village_id) + + return render_template( + "villages/view2.html", + village=village, + village_long_description_html=render_markdown2(village.long_description), + ) + + +def render_markdown2(markdown_text): + """Render untrusted markdown + + This doesn't have access to any templating unlike email markdown + which is from a trusted user so is pre-processed with jinja. + """ + extensions = ["markdown.extensions.nl2br", "markdown.extensions.smarty", "tables"] + contentHtml = nh3.clean( + markdown.markdown(markdown_text, extensions=extensions), tags=(nh3.ALLOWED_TAGS - {"img"}) + ) + innerHtml = f""" + +
+
+
+ {Markup(contentHtml)} +
+
+
""" + iFrameHtml = f'' + return Markup(iFrameHtml) + + @villages.route("///edit", methods=["GET", "POST"]) @login_required def edit(year: int, village_id: int) -> ResponseValue: diff --git a/css/_village.scss b/css/_village.scss new file mode 100644 index 000000000..5b07289ed --- /dev/null +++ b/css/_village.scss @@ -0,0 +1,16 @@ +.village-form { + textarea#description { + height: 100px; + } + + textarea#long_description { + height: 200px; + } +} + +.embedded-content { + width: 100%; + height: 450px; // default height, recalculated by javascript. + border: none; + overflow: hidden; +} \ No newline at end of file diff --git a/css/main.scss b/css/main.scss index 3f27400c2..cfddd01d7 100644 --- a/css/main.scss +++ b/css/main.scss @@ -20,6 +20,7 @@ @use "./_tickets.scss"; @use "./_responsive_table.scss"; @use "./_sponsorship.scss"; +@use "./_village.scss"; @use "./volunteer_schedule.scss"; @font-face { @@ -40,4 +41,4 @@ src: local(""), url("../static/fonts/raleway-v22-latin-ext_latin-700.woff2") format("woff2"), url("../static/fonts/raleway-v22-latin-ext_latin-700.woff") format("woff"); -} +} \ No newline at end of file diff --git a/js/sandboxed-iframe.js b/js/sandboxed-iframe.js new file mode 100644 index 000000000..01ad2b439 --- /dev/null +++ b/js/sandboxed-iframe.js @@ -0,0 +1,24 @@ +function sendFrameResizedMessage() { + //postMessage to set iframe height + window.parent.postMessage({ "type": "frame-resized", "value": document.body.parentElement.scrollHeight }, '*'); +} + +function listenForFrameResizedMessages(iFrameEle) { + window.addEventListener('message', receiveMessage, false); + + function receiveMessage(evt) { + console.log("Got message: " + JSON.stringify(evt.data) + " from origin: " + evt.origin); + // Do we trust the sender of this message? + // origin of sandboxed iframes is null but is this a useful check? + // if (evt.origin !== null) { + // return; + // } + + if (evt.data.type === "frame-resized") { + iFrameEle.style.height = evt.data.value + "px"; + } + } +} + +window.listenForFrameResizedMessages = listenForFrameResizedMessages; +window.sendFrameResizedMessage = sendFrameResizedMessage; \ No newline at end of file diff --git a/main.py b/main.py index a7feb87a6..b9a092184 100644 --- a/main.py +++ b/main.py @@ -284,6 +284,8 @@ def send_security_headers(response): "'unsafe-hashes'", "'sha256-2rvfFrggTCtyF5WOiTri1gDS8Boibj4Njn0e+VCBmDI='", # return false; "'sha256-gC0PN/M+TSxp9oNdolzpqpAA+ZRrv9qe1EnAbUuDmk8='", # return modelActions.execute('notify'); + "'sha256-GtgSCbDPm83G2B75TQfbv5TR/nqHF4WnrnN+njVmQFU='", # javascript:window.listenForFrameResizedMessages(this); + "'sha256-dW+ze6eYWjNQB4tjHLKsdbtI4AqFRK/FpdEu/ZCxmLc='", # javascript:window.sendFrameResizedMessage() ] if app.config.get("DEBUG_TB_ENABLED"): diff --git a/migrations/versions/6646de329427_add_long_description_to_village.py b/migrations/versions/6646de329427_add_long_description_to_village.py new file mode 100644 index 000000000..7bda2a076 --- /dev/null +++ b/migrations/versions/6646de329427_add_long_description_to_village.py @@ -0,0 +1,36 @@ +"""Add long_description to village + +Revision ID: 6646de329427 +Revises: 5062a9a72efc +Create Date: 2025-10-02 20:34:55.746599 + +""" + +# revision identifiers, used by Alembic. +revision = '6646de329427' +down_revision = '5062a9a72efc' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('village', schema=None) as batch_op: + batch_op.add_column(sa.Column('long_description', sa.String(), nullable=True)) + + with op.batch_alter_table('village_version', schema=None) as batch_op: + batch_op.add_column(sa.Column('long_description', sa.String(), autoincrement=False, nullable=True)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('village_version', schema=None) as batch_op: + batch_op.drop_column('long_description') + + with op.batch_alter_table('village', schema=None) as batch_op: + batch_op.drop_column('long_description') + + # ### end Alembic commands ### diff --git a/models/village.py b/models/village.py index 0343505b3..8fdd37f1a 100644 --- a/models/village.py +++ b/models/village.py @@ -31,6 +31,7 @@ class Village(BaseModel): name: Mapped[str] = mapped_column(unique=True) description: Mapped[str | None] + long_description: Mapped[str | None] url: Mapped[str | None] location: Mapped[WKBElement | None] = mapped_column(Geometry("POINT", srid=4326, spatial_index=False)) @@ -98,6 +99,7 @@ def get_export_data(cls): v.id: { "name": v.name, "description": v.description, + "long_description": v.long_description, "url": v.url, "location": v.latlon, } diff --git a/pyproject.toml b/pyproject.toml index 9c4430d0d..2d2b32ca5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,7 @@ dependencies = [ "logging_tree~=1.9", "lxml>=6.0.0", "markdown~=3.1", + "nh3>=0.3.1", "pendulum~=3.1", "pillow<12.0", "playwright>=1.43.0,<2", diff --git a/rsbuild.config.mjs b/rsbuild.config.mjs index 55e8ad438..cb698682b 100644 --- a/rsbuild.config.mjs +++ b/rsbuild.config.mjs @@ -69,6 +69,7 @@ export default defineConfig({ "volunteer_schedule.js": './js/volunteer-schedule.js', "event_tickets.js": './js/event-tickets.js', "arrivals.js": './js/arrivals.js', + "sandboxed-iframe.js": './js/sandboxed-iframe.js', "admin.scss": './css/admin.scss', "arrivals.scss": './css/arrivals.scss', diff --git a/templates/about/villages.md b/templates/about/villages.md index 8175e6363..8c575d927 100644 --- a/templates/about/villages.md +++ b/templates/about/villages.md @@ -1,7 +1,7 @@ title: Villages --- # Villages -A village is a group of friends, like-minded people, colleagues or even families camping together at EMF. Maybe your village is a collection of sub villages, or maybe itโ€™s just your hackerspace. A village can be based around themes, shared passions and existing communities - great places in the past have been the Maths village, the HAB Village and groups like Milliways and the Scottish Consulate. You can find the list of currently registered villages for EMF 2024 at [emfcamp.org/villages/{{ event_year }}](/villages/{{ event_year }}). +A village is a group of friends, like-minded people, colleagues or even families camping together at EMF. Maybe your village is a collection of sub villages, or maybe itโ€™s just your hackerspace. A village can be based around themes, shared passions and existing communities - great places in the past have been the Maths village, the HAB Village and groups like Milliways and the Scottish Consulate. You can find the list of currently registered villages for EMF {{ event_year }} at [emfcamp.org/villages/{{ event_year }}](/villages/{{ event_year }}). If there is a village based around something you are interested in, then you should definitely visit that village during the event to see what's going on. Or you could even join that village to help make more things go on. If there's not a village based around your interests, then you can start your own village and others will probably join you. diff --git a/templates/sandboxed-iframe.html b/templates/sandboxed-iframe.html new file mode 100644 index 000000000..753c9adbf --- /dev/null +++ b/templates/sandboxed-iframe.html @@ -0,0 +1,27 @@ +{# The source of an iFrame used to sandbox user-controlled HTML #} +{# Once loaded, this emits an event with it's content's size which the parent page can listen for to adapt the iFrame's height #} + + + + + {% block css -%} + + {% endblock -%} + {% block head -%}{% endblock -%} + + +{% block document %} +
+
+
+ {% block body -%} {{ body }} {% endblock -%} +
+
+
+{% endblock %} + +{% block foot -%}{% endblock -%} + + diff --git a/templates/villages/_form.html b/templates/villages/_form.html index a07267b82..856ec2318 100644 --- a/templates/villages/_form.html +++ b/templates/villages/_form.html @@ -1,5 +1,5 @@
-
+ {{ form.hidden_tag() }}
Basic Details @@ -8,7 +8,9 @@ {{ render_field(form.name, horizontal=9, placeholder="My Village") }} {{ render_field(form.url, horizontal=9, placeholder="") }} {{ render_field(form.description, horizontal=9, - placeholder="A description of your village. Who are you, what cool things will you be doing?") }} + placeholder="A short description of your village. Who are you, what cool things will you be doing?") }} + {{ render_field(form.long_description, horizontal=9, + placeholder="A longer description of your village using Markdown. Project details etc.") }}
Requirements diff --git a/templates/villages/admin/info.html b/templates/villages/admin/info.html index 2dbec741f..6c2a43c7f 100644 --- a/templates/villages/admin/info.html +++ b/templates/villages/admin/info.html @@ -15,6 +15,7 @@

Village Info: {{village.name}}

{{ render_field(form.name) }} {{ render_field(form.description) }} + {{ render_field(form.long_description) }} {{ render_field(form.url) }} {{ render_field(form.lat) }} {{ render_field(form.lon) }} diff --git a/templates/villages/view.html b/templates/villages/view.html new file mode 100644 index 000000000..149fad108 --- /dev/null +++ b/templates/villages/view.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} +{% block title %}Village: {{village.name}}{% endblock %} +{% block foot %}{% endblock%} +{% block body %} +{% if village.url %} +

{{village.name}}

+{% else %} +

{{village.name}}

+{% endif %} + +{% if village.description %} +

{{village.description}}

+{% endif %} + +{% if village_long_description_html %} +

{{village_long_description_html}}

+{% endif %} + +{# TODO: embed a map somehow? #} +{% if village.map_link %} +๐Ÿ“ Map +{% endif %} + +{# TODO: Can we show what's on at the village venues here? #} +{% endblock %} diff --git a/templates/villages/view2.html b/templates/villages/view2.html new file mode 100644 index 000000000..b0556a818 --- /dev/null +++ b/templates/villages/view2.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} +{% block title %}Village: {{village.name}}{% endblock %} +{% block body %} +{% if village.url %} +

{{village.name}}

+{% else %} +

{{village.name}}

+{% endif %} + +{% if village.description %} +

{{village.description}}

+{% endif %} + +{% if village_long_description_html %} +

{{village_long_description_html}}

+{% endif %} + +{# TODO: embed a map somehow? #} +{% if village.map_link %} +๐Ÿ“ Map +{% endif %} + +{# TODO: Can we show what's on at the village venues here? #} +{% endblock %} diff --git a/templates/villages/villages.html b/templates/villages/villages.html index b8ec8c881..ba0e50462 100644 --- a/templates/villages/villages.html +++ b/templates/villages/villages.html @@ -23,11 +23,7 @@

List of Villages

{% for village in villages %} - {% if village.url %} - {{village.name}} - {% else %} - {{village.name}} - {% endif %} + {{village.name}} {{village.description}} {% if any_village_located %} diff --git a/uv.lock b/uv.lock index b6e7d3b5b..306eb2b3c 100644 --- a/uv.lock +++ b/uv.lock @@ -1098,6 +1098,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] +[[package]] +name = "nh3" +version = "0.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/a6/c6e942fc8dcadab08645f57a6d01d63e97114a30ded5f269dc58e05d4741/nh3-0.3.1.tar.gz", hash = "sha256:6a854480058683d60bdc7f0456105092dae17bef1f300642856d74bd4201da93", size = 18590, upload-time = "2025-10-07T03:27:58.217Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/24/4becaa61e066ff694c37627f5ef7528901115ffa17f7a6693c40da52accd/nh3-0.3.1-cp313-cp313t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:80dc7563a2a3b980e44b221f69848e3645bbf163ab53e3d1add4f47b26120355", size = 1420887, upload-time = "2025-10-07T03:27:25.654Z" }, + { url = "https://files.pythonhosted.org/packages/94/49/16a6ec9098bb9bdf0fb9f09d6464865a3a48858d8d96e779a998ec3bdce0/nh3-0.3.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f600ad86114df21efc4a3592faa6b1d099c0eebc7e018efebb1c133376097da", size = 791700, upload-time = "2025-10-07T03:27:27.041Z" }, + { url = "https://files.pythonhosted.org/packages/1d/cc/1c024d7c23ad031dfe82ad59581736abcc403b006abb0d2785bffa768b54/nh3-0.3.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:669a908706cd28203d9cfce2f567575686e364a1bc6074d413d88d456066f743", size = 830225, upload-time = "2025-10-07T03:27:28.315Z" }, + { url = "https://files.pythonhosted.org/packages/89/08/4a87f9212373bd77bba01c1fd515220e0d263316f448d9c8e4b09732a645/nh3-0.3.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a5721f59afa0ab3dcaa0d47e58af33a5fcd254882e1900ee4a8968692a40f79d", size = 999112, upload-time = "2025-10-07T03:27:29.782Z" }, + { url = "https://files.pythonhosted.org/packages/19/cf/94783911eb966881a440ba9641944c27152662a253c917a794a368b92a3c/nh3-0.3.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:2cb6d9e192fbe0d451c7cb1350dadedbeae286207dbf101a28210193d019752e", size = 1070424, upload-time = "2025-10-07T03:27:31.2Z" }, + { url = "https://files.pythonhosted.org/packages/71/44/efb57b44e86a3de528561b49ed53803e5d42cd0441dcfd29b89422160266/nh3-0.3.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:474b176124c1b495ccfa1c20f61b7eb83ead5ecccb79ab29f602c148e8378489", size = 996129, upload-time = "2025-10-07T03:27:32.595Z" }, + { url = "https://files.pythonhosted.org/packages/ee/d3/87c39ea076510e57ee99a27fa4c2335e9e5738172b3963ee7c744a32726c/nh3-0.3.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4a2434668f4eef4eab17c128e565ce6bea42113ce10c40b928e42c578d401800", size = 980310, upload-time = "2025-10-07T03:27:34.282Z" }, + { url = "https://files.pythonhosted.org/packages/bc/30/00cfbd2a4d268e8d3bda9d1542ba4f7a20fbed37ad1e8e51beeee3f6fdae/nh3-0.3.1-cp313-cp313t-win32.whl", hash = "sha256:0f454ba4c6aabafcaae964ae6f0a96cecef970216a57335fabd229a265fbe007", size = 584439, upload-time = "2025-10-07T03:27:36.103Z" }, + { url = "https://files.pythonhosted.org/packages/80/fa/39d27a62a2f39eb88c2bd50d9fee365a3645e456f3ec483c945a49c74f47/nh3-0.3.1-cp313-cp313t-win_amd64.whl", hash = "sha256:22b9e9c9eda497b02b7273b79f7d29e1f1170d2b741624c1b8c566aef28b1f48", size = 592388, upload-time = "2025-10-07T03:27:37.075Z" }, + { url = "https://files.pythonhosted.org/packages/7c/39/7df1c4ee13ef65ee06255df8101141793e97b4326e8509afbce5deada2b5/nh3-0.3.1-cp313-cp313t-win_arm64.whl", hash = "sha256:42e426f36e167ed29669b77ae3c4b9e185e4a1b130a86d7c3249194738a1d7b2", size = 579337, upload-time = "2025-10-07T03:27:38.055Z" }, + { url = "https://files.pythonhosted.org/packages/e1/28/a387fed70438d2810c8ac866e7b24bf1a5b6f30ae65316dfe4de191afa52/nh3-0.3.1-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:1de5c1a35bed19a1b1286bab3c3abfe42e990a8a6c4ce9bb9ab4bde49107ea3b", size = 1433666, upload-time = "2025-10-07T03:27:39.118Z" }, + { url = "https://files.pythonhosted.org/packages/c7/f9/500310c1f19cc80770a81aac3c94a0c6b4acdd46489e34019173b2b15a50/nh3-0.3.1-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eaba26591867f697cffdbc539faddeb1d75a36273f5bfe957eb421d3f87d7da1", size = 819897, upload-time = "2025-10-07T03:27:40.488Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d4/ebb0965d767cba943793fa8f7b59d7f141bd322c86387a5e9485ad49754a/nh3-0.3.1-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:489ca5ecd58555c2865701e65f614b17555179e71ecc76d483b6f3886b813a9b", size = 803562, upload-time = "2025-10-07T03:27:41.86Z" }, + { url = "https://files.pythonhosted.org/packages/0a/9c/df037a13f0513283ecee1cf99f723b18e5f87f20e480582466b1f8e3a7db/nh3-0.3.1-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:5a25662b392b06f251da6004a1f8a828dca7f429cd94ac07d8a98ba94d644438", size = 1050854, upload-time = "2025-10-07T03:27:43.29Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9d/488fce56029de430e30380ec21f29cfaddaf0774f63b6aa2bf094c8b4c27/nh3-0.3.1-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:38b4872499ab15b17c5c6e9f091143d070d75ddad4a4d1ce388d043ca556629c", size = 1002152, upload-time = "2025-10-07T03:27:44.358Z" }, + { url = "https://files.pythonhosted.org/packages/da/4a/24b0118de34d34093bf03acdeca3a9556f8631d4028814a72b9cc5216382/nh3-0.3.1-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48425995d37880281b467f7cf2b3218c1f4750c55bcb1ff4f47f2320a2bb159c", size = 912333, upload-time = "2025-10-07T03:27:45.757Z" }, + { url = "https://files.pythonhosted.org/packages/11/0e/16b3886858b3953ef836dea25b951f3ab0c5b5a431da03f675c0e999afb8/nh3-0.3.1-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94292dd1bd2a2e142fa5bb94c0ee1d84433a5d9034640710132da7e0376fca3a", size = 796945, upload-time = "2025-10-07T03:27:47.169Z" }, + { url = "https://files.pythonhosted.org/packages/87/bb/aac139cf6796f2e0fec026b07843cea36099864ec104f865e2d802a25a30/nh3-0.3.1-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:dd6d1be301123a9af3263739726eeeb208197e5e78fc4f522408c50de77a5354", size = 837257, upload-time = "2025-10-07T03:27:48.243Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d7/1d770876a288a3f5369fd6c816363a5f9d3a071dba24889458fdeb4f7a49/nh3-0.3.1-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b74bbd047b361c0f21d827250c865ff0895684d9fcf85ea86131a78cfa0b835b", size = 1004142, upload-time = "2025-10-07T03:27:49.278Z" }, + { url = "https://files.pythonhosted.org/packages/31/2a/c4259e8b94c2f4ba10a7560e0889a6b7d2f70dce7f3e93f6153716aaae47/nh3-0.3.1-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:b222c05ae5139320da6caa1c5aed36dd0ee36e39831541d9b56e048a63b4d701", size = 1075896, upload-time = "2025-10-07T03:27:50.527Z" }, + { url = "https://files.pythonhosted.org/packages/59/06/b15ba9fea4773741acb3382dcf982f81e55f6053e8a6e72a97ac91928b1d/nh3-0.3.1-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:b0d6c834d3c07366ecbdcecc1f4804c5ce0a77fa52ee4653a2a26d2d909980ea", size = 1003235, upload-time = "2025-10-07T03:27:51.673Z" }, + { url = "https://files.pythonhosted.org/packages/1d/13/74707f99221bbe0392d18611b51125d45f8bd5c6be077ef85575eb7a38b1/nh3-0.3.1-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:670f18b09f75c86c3865f79543bf5acd4bbe2a5a4475672eef2399dd8cdb69d2", size = 987308, upload-time = "2025-10-07T03:27:53.003Z" }, + { url = "https://files.pythonhosted.org/packages/ee/81/24bf41a5ce7648d7e954de40391bb1bcc4b7731214238c7138c2420f962c/nh3-0.3.1-cp38-abi3-win32.whl", hash = "sha256:d7431b2a39431017f19cd03144005b6c014201b3e73927c05eab6ca37bb1d98c", size = 591695, upload-time = "2025-10-07T03:27:54.43Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ca/263eb96b6d32c61a92c1e5480b7f599b60db7d7fbbc0d944be7532d0ac42/nh3-0.3.1-cp38-abi3-win_amd64.whl", hash = "sha256:c0acef923a1c3a2df3ee5825ea79c149b6748c6449781c53ab6923dc75e87d26", size = 600564, upload-time = "2025-10-07T03:27:55.966Z" }, + { url = "https://files.pythonhosted.org/packages/34/67/d5e07efd38194f52b59b8af25a029b46c0643e9af68204ee263022924c27/nh3-0.3.1-cp38-abi3-win_arm64.whl", hash = "sha256:a3e810a92fb192373204456cac2834694440af73d749565b4348e30235da7f0b", size = 586369, upload-time = "2025-10-07T03:27:57.234Z" }, +] + [[package]] name = "numpy" version = "2.3.2" @@ -2163,6 +2196,7 @@ dependencies = [ { name = "logging-tree" }, { name = "lxml" }, { name = "markdown" }, + { name = "nh3" }, { name = "pendulum" }, { name = "pillow" }, { name = "playwright" }, @@ -2250,6 +2284,7 @@ requires-dist = [ { name = "logging-tree", specifier = "~=1.9" }, { name = "lxml", specifier = ">=6.0.0" }, { name = "markdown", specifier = "~=3.1" }, + { name = "nh3", specifier = ">=0.3.1" }, { name = "pendulum", specifier = "~=3.1" }, { name = "pillow", specifier = "<12.0" }, { name = "playwright", specifier = ">=1.43.0,<2" },