From 6ae74c163a991d9b7a54b8d37c4843f6d1093a5e Mon Sep 17 00:00:00 2001 From: Andrew Shirley Date: Thu, 2 Oct 2025 20:22:11 +0000 Subject: [PATCH 1/3] use https instead of ssh as ssh isn't on the dev container --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From a076987d05f747b968a2f6902033dd49ffcc7184 Mon Sep 17 00:00:00 2001 From: Andrew Shirley Date: Thu, 2 Oct 2025 20:22:59 +0000 Subject: [PATCH 2/3] Add a view page for an individual village --- apps/villages/views.py | 7 +++++++ templates/about/villages.md | 2 +- templates/villages/view.html | 21 +++++++++++++++++++++ templates/villages/villages.html | 6 +----- 4 files changed, 30 insertions(+), 6 deletions(-) create mode 100644 templates/villages/view.html diff --git a/apps/villages/views.py b/apps/villages/views.py index 9dfc04757..fcb0db660 100644 --- a/apps/villages/views.py +++ b/apps/villages/views.py @@ -65,6 +65,13 @@ 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) + + @villages.route("///edit", methods=["GET", "POST"]) @login_required def edit(year: int, village_id: int) -> ResponseValue: 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/villages/view.html b/templates/villages/view.html new file mode 100644 index 000000000..0848cddf6 --- /dev/null +++ b/templates/villages/view.html @@ -0,0 +1,21 @@ +{% from "_formhelpers.html" import render_field %} +{% 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 %} + +{# 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 %} From ad0028156b4e483bd5ed288b8d3f589f6f2d8b58 Mon Sep 17 00:00:00 2001 From: Andrew Shirley Date: Sun, 19 Oct 2025 20:49:21 +0000 Subject: [PATCH 3/3] Add a long description and a couple of ways of rendering --- apps/villages/forms.py | 3 + apps/villages/views.py | 60 ++++++++++++++++++- css/_village.scss | 16 +++++ css/main.scss | 3 +- js/sandboxed-iframe.js | 24 ++++++++ main.py | 2 + ...e329427_add_long_description_to_village.py | 36 +++++++++++ models/village.py | 2 + pyproject.toml | 1 + rsbuild.config.mjs | 1 + templates/sandboxed-iframe.html | 27 +++++++++ templates/villages/_form.html | 6 +- templates/villages/admin/info.html | 1 + templates/villages/view.html | 6 +- templates/villages/view2.html | 24 ++++++++ uv.lock | 35 +++++++++++ 16 files changed, 242 insertions(+), 5 deletions(-) create mode 100644 css/_village.scss create mode 100644 js/sandboxed-iframe.js create mode 100644 migrations/versions/6646de329427_add_long_description_to_village.py create mode 100644 templates/sandboxed-iframe.html create mode 100644 templates/villages/view2.html 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 fcb0db660..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 @@ -69,7 +74,60 @@ def main(year: int) -> ResponseValue: def view(year: int, village_id: int) -> ResponseValue: village = load_village(year, village_id) - return render_template("villages/view.html", village=village) + 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"]) 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/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 index 0848cddf6..149fad108 100644 --- a/templates/villages/view.html +++ b/templates/villages/view.html @@ -1,6 +1,6 @@ -{% from "_formhelpers.html" import render_field %} {% extends "base.html" %} {% block title %}Village: {{village.name}}{% endblock %} +{% block foot %}{% endblock%} {% block body %} {% if village.url %}

{{village.name}}

@@ -12,6 +12,10 @@

{{village.name}}

{{village.description}}

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

{{village_long_description_html}}

+{% endif %} + {# TODO: embed a map somehow? #} {% if village.map_link %} πŸ“ Map 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/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" },