From da00f1f844a2d2aa1feb8cec849642dfaabcb95e Mon Sep 17 00:00:00 2001 From: Mark Buckaway Date: Tue, 22 Sep 2026 22:45:27 -0400 Subject: [PATCH 1/5] feat(results): Publish to WordPress Add Results > Publish to WordPress..., which creates or updates a page on a WordPress site through its REST API, authenticated with an Application Password over HTTPS. - rivercrossing.wordpress: wx-free REST client (discover, find_page_by_slug, publish_page) built on the stdlib only, HTTPS-only, with typed errors. - htmlexport.render_wordpress plus a selector-scoped compiled_css_wp artifact: it emits only an inner body fragment wrapped in .rc-results, so publishing cannot restyle the site theme. Cascade layers are flattened so the fragment's rules win on specificity against a theme's unlayered CSS, keeping the app's own layout. - publish_wordpress_dlg and mi_publish_wordpress, the pure presenter and the wx view, and the off-loop publish wiring in the app (errors surfaced via the status bar or a native dialog). - Five wp_* settings persisted in the settings file (the Application Password is stored there for now). - Unit tests cover the client (100% branch), presenter, renderer and settings; the frozen-name registry and CHANGELOG are updated. Verified against a live local WordPress (Podman): discover, create-then-update, and the rendered page. --- CHANGELOG.md | 9 + design/docs-md/spec.md | 7 +- design/docs-md/xrc-windows.md | 18 + noxfile.py | 20 +- pyproject.toml | 15 +- src/rivercrossing/htmlexport/__init__.py | 100 ++- .../htmlexport/templates/compiled_css_wp | 4 + .../htmlexport/templates/wordpress.html.j2 | 137 ++++ src/rivercrossing/ui/app.py | 200 ++++- src/rivercrossing/ui/commands.py | 19 +- src/rivercrossing/ui/ids.py | 9 + .../ui/presenters/publish_wordpress.py | 219 +++++ src/rivercrossing/ui/presenters/settings.py | 62 +- src/rivercrossing/ui/views/dialogs.py | 3 + .../ui/views/publish_wordpress.py | 184 +++++ src/rivercrossing/ui/views/settings.py | 15 +- src/rivercrossing/ui/xrc/dialogs.xrc | 181 ++++- src/rivercrossing/ui/xrc/main.xrc | 17 +- src/rivercrossing/wordpress.py | 491 +++++++++++ .../unit/presenters/test_publish_wordpress.py | 259 ++++++ tests/unit/presenters/test_settings.py | 192 +++++ tests/unit/test_check_asset_manifest.py | 12 +- tests/unit/test_gen_css.py | 320 +++++++- tests/unit/test_htmlexport_wordpress.py | 244 ++++++ tests/unit/test_wordpress.py | 767 ++++++++++++++++++ tests/unit/test_xrc_structure.py | 26 +- tests/unit/ui/test_commands.py | 25 +- tests/unit/ui/test_menu_state.py | 2 +- tests/unit/ui/test_settings_contract.py | 25 + tools/check_asset_manifest.py | 22 +- tools/gen_css.py | 383 ++++++++- 31 files changed, 3875 insertions(+), 112 deletions(-) create mode 100644 src/rivercrossing/htmlexport/templates/compiled_css_wp create mode 100644 src/rivercrossing/htmlexport/templates/wordpress.html.j2 create mode 100644 src/rivercrossing/ui/presenters/publish_wordpress.py create mode 100644 src/rivercrossing/ui/views/publish_wordpress.py create mode 100644 src/rivercrossing/wordpress.py create mode 100644 tests/unit/presenters/test_publish_wordpress.py create mode 100644 tests/unit/test_htmlexport_wordpress.py create mode 100644 tests/unit/test_wordpress.py diff --git a/CHANGELOG.md b/CHANGELOG.md index aee1aba8..b1703c7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ All notable changes to RiverCrossing are recorded here. The format follows ## [Unreleased] +### Added + +- **Publish results to WordPress** — the Results menu gains **Publish to WordPress…**, which creates + or updates a page on a WordPress site through its REST API (an Application Password over HTTPS). + The dialog collects the site URL, username, Application Password, page title and slug, an optional + parent "section" page, and the publish status; the connection and page defaults persist in the + settings file. The published content is the app's own results layout, scoped so it cannot restyle + the site's theme. + ## [1.1.0] - 2026-09-21 ### Added diff --git a/design/docs-md/spec.md b/design/docs-md/spec.md index dfae3077..4ae1e727 100644 --- a/design/docs-md/spec.md +++ b/design/docs-md/spec.md @@ -352,6 +352,7 @@ One menu tree on both platforms (wx relocates About / Settings / Quit into the m | **Results ▸ Preview HTML in Browser** | Opens the last HTML export in the default browser | — | FINISHED, an HTML export exists this session | | **Results ▸ Preview Podium Poster HTML in Browser** | Opens the last podium-poster page export in the default browser | — | FINISHED, a poster page export exists this session | | **Results ▸ Preview PDF in Browser** | Opens the last PDF export — the results PDF or the podium poster — in the default browser | — | FINISHED, a PDF export exists this session | +| **Results ▸ Publish to WordPress…** | WordPress publish form (`dialogs.xrc`) — the site and page fields, prefilled from the stored settings and the live ride; OK persists the site, renders the results fragment (`htmlexport.render_wordpress`) and publishes it through the site's REST API on a background thread — creating the page, or updating the one already at that slug (Phase 4b) | — | FINISHED | | **Results ▸ Show lap & total times** | Checkable publish option (the five sit after a separator) — times in the published results, and the enabler for the Fastest-time leaderboard (R-63); persisted as `AppSettings.publish_show_times` (off by default) | — | always | | **Results ▸ Laps leaderboard** | Checkable publish option — the laps board in the exports; `AppSettings.publish_laps_board` (on by default) | — | always | | **Results ▸ Fastest-time leaderboard** | Checkable publish option — the time board in the exports; `AppSettings.publish_time_board` (off by default), unchecked and disabled while Show lap & total times is off (R-63) | — | always | @@ -377,11 +378,13 @@ Every window and dialog is defined in **wxWidgets XRC** (sizer-based, resizable, - **E7 mock-first addition — `void_card_confirm_dlg`:** the last §15 row (Cards ▸ Void Card…) that until E7 had no frozen window and routed to E1.4.1's `_UNAUTHORED_DIALOG` sentinel. Authored mock-first: a confirm naming the card being voided — a two-column label grid (caption "Card" → `card_lbl`, caption "Entry" → `entry_lbl`, both code-written and never blank; UX-DESKTOP §4), with the fixed sentence "Removes the card from the entry's scored hand. Audit-logged." below the grid, and `reason_input` (shared name, reason required, audit-logged), stock `wxID_OK "Void card"` (disabled until the reason box holds non-empty text) + `wxID_CANCEL` (default + focused — a scoring change, like the destructive confirms). Replaces the sentinel; no §15 route remains on it. +- **Phase 4b addition — `publish_wordpress_dlg` (Results ▸ Publish to WordPress…):** no mock canvas preceded this form. Two labelled `wxStaticBoxSizer` groups — **Site** (`wp_url_input`, `wp_username_input`, `wp_password_input`, a `wxTE_PASSWORD` field so the Application Password is never shown in clear) and **Page** (`wp_title_input`, `wp_slug_input`, `wp_parent_input`, `wp_status_choice`) — each over a 2-column caption/value `wxFlexGridSizer`; a fixed help line (the Application Password is kept in this machine's settings file for now; a blank parent page publishes at the top level; the site account needs permission to publish pages with inline styles); and the stock `wxID_OK "Publish"` (default) + `wxID_CANCEL` row. `wp_status_choice` authors no `` — the view fills it from `presenters/publish_wordpress.STATUS_CHOICES` (Draft / Publish / Pending / Private) and selects the stored `wp_status`. The five site fields are new settings (`AppSettings.wp_url`/`wp_username`/`wp_password`/`wp_parent`/`wp_status`); the initial focus is `wp_url_input` (`views.dialogs.FORM_FIRST_FIELDS`). A blank field, or a site URL that does not begin `https://`, refuses the publish in a native alert and leaves the dialog open. + - **Results rework + Rider Simulator (this batch):** `results_frame` (a modeless `wxFrame`) is now the modal `wxDialog` **`results_dlg`** in `results.xrc`, opened through `views.dialogs.run_dialog`. Its tie-break order panel and `reopen_btn` are gone — the tie-break order now lives only in `ride_setup_dlg` (`setup.xrc`'s own `tiebreak_list`), and reopening a ride is still Ride ▸ Reopen Ride (`mi_reopen_ride`); `mi_tiebreak_order` retired with the panel. A `wxNotebook` named `results_notebook` carries the **Teams** (`teams_standings_list`) and **Solo** (`solo_standings_list`) pages in a MIXED ride; a SOLO ride hides the notebook and renders every rider in the standalone `standings_list` — the old inline "Teams"/"Solo" section-header rows inside one list are gone. All three standings lists take `DATAVIEW_COL_SORTABLE | DATAVIEW_COL_RESIZABLE`, so their columns sort and resize natively; `StandingsRow` gained `total_seconds` so the Total column sorts numerically rather than by its rendered text. `wxID_CLOSE` joined the bottom export-button row — now the row's only member — and the window's `MIN_SIZE` is 800×442 (R-14: the seven pinned standings columns now total 750 px, plus the list's scrollbar and the notebook/sizer borders) so its seven pinned standings columns fit (G6 dropped the Total and Best lap columns and moved the five publish options out to checkable Results-menu items; R-14 appended the Draw column). The 1.0.17 follow-up floors the three standings lists at 198 px — a 28 px header plus 10 × 17 px rows, measured on wxPython 4.3.1 — so ten standings rows are visible. The four publish/export buttons (`export_html_btn`, `export_pdf_btn`, `poster_btn`, `export_csv_btn`) are disabled until the ride is FINISHED — retired in the UI-removals batch for the Results menu's own `mi_export_*` rows, which are the single export surface now — and this batch's single preview item, `mi_preview_browser`, needed FINISHED **and** an export — the later results refactor retired it for the per-format `mi_preview_html_browser` / `mi_preview_pdf_browser` (the poster-page row, `mi_preview_poster_html_browser`, joined them in the UI-removals batch), each enabled only after its own format's export. The new `simulation.xrc` carries the **Rider Simulator**: `simulation_dlg` (`riders_spin` "Number of riders", `teams_spin`, `solo_spin`, `laps_spin`, `interval_spin` (each a stored setting, seeded back on open and persisted on close — `sim_riders`/`sim_teams`/`sim_solo`/`sim_laps`/`sim_interval`), `short_lap_choice`/`lapped_choice`/`team_stop_choice` (the G9 behaviour dropdowns — "Short-lap riders", "Lapped riders" and "Team riders stop after 4 laps", each Disabled or 1..10 and each seeded from `sim_short_laps`/`sim_lapped`/`sim_team_stop`), `gen_riders_btn` "Generate Riders", `check_btn` "Check", `go_btn` "GO", stock `wxID_CANCEL`, and the code-side `sim_infobar`; the ride-sim/corrections batch retired the Generate Teams button, so this one generator creates the requested teams first when the roster holds none and then the riders) and `sim_running_dlg` (`sim_status_lbl`, `progress_gauge` — a `wxGauge` percent-complete bar — and `cancel_btn`), reached from File ▸ Simulation… (`mi_simulation`, enabled in DRAFT — open or none — or stopped RUNNING). It generates `TEAM-####` teams and `FIRSTNAME-####`/`LASTNAME-####` riders (solo riders first, team riders attached round-robin, random M/F, plates from the roster's next free plate); GO starts the DRAFT ride and records every generated crossing through the console's own `RideEngine.record_crossing(plate, at=…)` seam, then `RideEngine.stop()` leaves it stopped-RUNNING; the modal progress dialog reports percent complete and can cancel the run. -- **Files (src/rivercrossing/ui/xrc/):** main.xrc → main_frame + main_menubar · setup.xrc → ride_setup_dlg · riders.xrc → rider_editor_dlg + add_rider_dlg (W7; one window serving Add and Edit modes since Phase 3) + csv_preview_dlg + rider_issues_dlg (R-78) + start_blocked_dlg (Phase 5; the "Cannot Start Ride" issue list, sized 2× code-side) · teams.xrc → team_editor_dlg (Phase 4) + add_team_dlg (W8; one window serving Add and Edit modes since Phase 3) · results.xrc → results_dlg · simulation.xrc → simulation_dlg + sim_running_dlg (the Rider Simulator) · library.xrc → ride_library_dlg + delete_ride_dlg · audit.xrc → audit_dlg · settings.xrc → settings_dlg · dialogs.xrc → set_start_dlg, resume_dlg, exit_running_dlg, edit_crossing_dlg, manual_deal_dlg, dnf_confirm_dlg, void_card_confirm_dlg, about_dlg, selftest_dlg, shortcuts_dlg, crossing_detail_dlg (J2 — its `edit_time_btn`/`void_card_btn` corrections grew in the scoring-and-corrections batch) and crossing_number_dlg (§9; twelve windows; `reassign_dlg` retired in the scoring-and-corrections batch) — `stop_confirm_dlg` retired in W5 (native confirm, R-85), `no_ride_dlg` in W15 (its launch surface shows nothing at all now — the native info alert that replaced it was removed, R-80 retired), and `finish_confirm_dlg`, `exit_confirm_dlg`, `duplicate_ride_dlg` and `reopen_ride_dlg` in H2 (native `show_danger`/`show_confirm`/`show_prompt`, R-85). +- **Files (src/rivercrossing/ui/xrc/):** main.xrc → main_frame + main_menubar · setup.xrc → ride_setup_dlg · riders.xrc → rider_editor_dlg + add_rider_dlg (W7; one window serving Add and Edit modes since Phase 3) + csv_preview_dlg + rider_issues_dlg (R-78) + start_blocked_dlg (Phase 5; the "Cannot Start Ride" issue list, sized 2× code-side) · teams.xrc → team_editor_dlg (Phase 4) + add_team_dlg (W8; one window serving Add and Edit modes since Phase 3) · results.xrc → results_dlg · simulation.xrc → simulation_dlg + sim_running_dlg (the Rider Simulator) · library.xrc → ride_library_dlg + delete_ride_dlg · audit.xrc → audit_dlg · settings.xrc → settings_dlg · dialogs.xrc → set_start_dlg, resume_dlg, exit_running_dlg, edit_crossing_dlg, manual_deal_dlg, dnf_confirm_dlg, void_card_confirm_dlg, about_dlg, selftest_dlg, shortcuts_dlg, crossing_detail_dlg (J2 — its `edit_time_btn`/`void_card_btn` corrections grew in the scoring-and-corrections batch) and crossing_number_dlg (§9) and publish_wordpress_dlg (Phase 4b — the Results publish form; thirteen windows; `reassign_dlg` retired in the scoring-and-corrections batch) — `stop_confirm_dlg` retired in W5 (native confirm, R-85), `no_ride_dlg` in W15 (its launch surface shows nothing at all now — the native info alert that replaced it was removed, R-80 retired), and `finish_confirm_dlg`, `exit_confirm_dlg`, `duplicate_ride_dlg` and `reopen_ride_dlg` in H2 (native `show_danger`/`show_confirm`/`show_prompt`, R-85). -- **Menu item names** (map 1:1 to §15 rows): File — mi_open_library, mi_duplicate_ride, mi_import_csv, mi_export_csv, mi_backup_now, mi_simulation (open DRAFT ride only — the Rider Simulator), wxID_PREFERENCES (Settings…), wxID_EXIT · Ride — mi_new_ride (D1: moved here from File so one surface owns the whole ride lifecycle), mi_edit_ride (D2), mi_start_ride, mi_stop_ride, mi_set_start_time, mi_finish_ride, mi_reopen_ride, mi_audit_trail, mi_clear_ride (D3: after a separator, being the menu's one destructive row; W14's `mi_ride_setup` left with the removed Ride Setup row) · Riders — mi_rider_editor, mi_team_editor (Phase 4), mi_check_rider_issues (R-78), mi_mark_dnf (D4: `mi_add_entry` retired — the Rider Editor row is the one entry point for adding riders; `mi_entry_detail` left with the retired Entry Detail window) · Cards — mi_undo_crossing, mi_add_crossing_at, mi_deal_manual (`Deal Bonus Card…` — the manual-deal row renamed in the scoring-and-corrections batch; `mi_reassign_plate`/`mi_void_card` left with the retired Entry Detail window, their corrections now Crossing Detail's own `edit_time_btn`/`void_card_btn`; `mi_edit_crossing` left in the 1.0.17 follow-up — Crossing Detail's Edit Time and the F2 accelerator cover it; `mi_review_held` retired in the UI-removals batch — the console's Review… button is the page's single surface) · Results — mi_standings (F5; always available), mi_export_html, mi_export_pdf, mi_export_poster, mi_export_poster_html, mi_export_results_csv, mi_preview_html_browser, mi_preview_poster_html_browser, mi_preview_pdf_browser (each needs FINISHED plus its own format's export — the poster page's own row is satisfied by neither the results page nor the PDF), mi_show_times, mi_laps_board, mi_time_board, mi_full_field, mi_all_cards (the five checkable publish options, after a separator; `mi_time_board` is additionally unchecked and disabled while Show lap & total times is off, R-63) · View — mi_show_total_times, mi_show_lap_time (two independent check items, R-37: the Total column and the Lap time column), mi_zoom_90…mi_zoom_150 (radio items) — the three `mi_theme_*` radios left with W13, since the Settings appearance radios are the single theme surface · Help — mi_user_guide, mi_shortcuts, mi_selftest, wxID_ABOUT. +- **Menu item names** (map 1:1 to §15 rows): File — mi_open_library, mi_duplicate_ride, mi_import_csv, mi_export_csv, mi_backup_now, mi_simulation (open DRAFT ride only — the Rider Simulator), wxID_PREFERENCES (Settings…), wxID_EXIT · Ride — mi_new_ride (D1: moved here from File so one surface owns the whole ride lifecycle), mi_edit_ride (D2), mi_start_ride, mi_stop_ride, mi_set_start_time, mi_finish_ride, mi_reopen_ride, mi_audit_trail, mi_clear_ride (D3: after a separator, being the menu's one destructive row; W14's `mi_ride_setup` left with the removed Ride Setup row) · Riders — mi_rider_editor, mi_team_editor (Phase 4), mi_check_rider_issues (R-78), mi_mark_dnf (D4: `mi_add_entry` retired — the Rider Editor row is the one entry point for adding riders; `mi_entry_detail` left with the retired Entry Detail window) · Cards — mi_undo_crossing, mi_add_crossing_at, mi_deal_manual (`Deal Bonus Card…` — the manual-deal row renamed in the scoring-and-corrections batch; `mi_reassign_plate`/`mi_void_card` left with the retired Entry Detail window, their corrections now Crossing Detail's own `edit_time_btn`/`void_card_btn`; `mi_edit_crossing` left in the 1.0.17 follow-up — Crossing Detail's Edit Time and the F2 accelerator cover it; `mi_review_held` retired in the UI-removals batch — the console's Review… button is the page's single surface) · Results — mi_standings (F5; always available), mi_export_html, mi_export_pdf, mi_export_poster, mi_export_poster_html, mi_export_results_csv, mi_preview_html_browser, mi_preview_poster_html_browser, mi_preview_pdf_browser (each needs FINISHED plus its own format's export — the poster page's own row is satisfied by neither the results page nor the PDF), mi_publish_wordpress (Publish to WordPress… — Phase 4b: the WordPress publish form, `publish_wordpress_dlg`), mi_show_times, mi_laps_board, mi_time_board, mi_full_field, mi_all_cards (the five checkable publish options, after a separator; `mi_time_board` is additionally unchecked and disabled while Show lap & total times is off, R-63) · View — mi_show_total_times, mi_show_lap_time (two independent check items, R-37: the Total column and the Lap time column), mi_zoom_90…mi_zoom_150 (radio items) — the three `mi_theme_*` radios left with W13, since the Settings appearance radios are the single theme surface · Help — mi_user_guide, mi_shortcuts, mi_selftest, wxID_ABOUT. - **Declared in XRC vs code:** XRC owns structure, labels, sizers, styles (wxRB_GROUP radio groups inside wxStaticBoxSizer, wxStdDialogButtonSizer button rows, wxSplitterWindow, wxDataViewCtrl shells). Code owns: DataView columns/rows/attributes, card imagelist (53 bitmaps @1x/2x from ui/assets/cards/), InfoBar construction + messages + show/hide, splitter sash restore, window minimum sizes, radio menu-item defaults, per-state menu enabling, SetAppearance. Custom-drawn widgets: none. diff --git a/design/docs-md/xrc-windows.md b/design/docs-md/xrc-windows.md index 1a779e7e..8de4ff3f 100644 --- a/design/docs-md/xrc-windows.md +++ b/design/docs-md/xrc-windows.md @@ -603,3 +603,21 @@ Simulated 240 of 1 800`sim_status_lbl` Cancel`cancel_btn` ⚠ code-side: `sim_running_dlg` is the modal progress window GO opens — the status line reads "Simulated of crossings" and the gauge holds `100 * completed // total`, both refreshed after every recorded crossing. The race runs on the main thread inside this dialog's own modal loop, with `wx.Yield()` after each crossing so the gauge repaints and a Cancel click is dispatched without waiting for the race to end; Cancel (or Escape, which the view points at it with SetEscapeId, since cancel_btn carries a custom id) ends the run early. The window lives in simulation.xrc (§15b). + +H · WordPress publish (Phase 4b) + +Publish to WordPress`publish_wordpress_dlg`✕ + +▣ Site — Site URL / Username / Application password + +▣ Page — Title / Slug / Parent page (id or slug) / Status + +`wp_url_input (focused — the form's first field, recorded in views.dialogs.FORM_FIRST_FIELDS) · wp_username_input · wp_password_input (a wxTE_PASSWORD field, so the Application Password is never shown in clear) — one "Site" wxStaticBoxSizer over a 2-column caption/value wxFlexGridSizer, each input preceded by its own unnamed label (UX-DESKTOP §7: never placeholder text) · wp_title_input · wp_slug_input · wp_parent_input · wp_status_choice (authored with no ``: the view fills it from `presenters/publish_wordpress.STATUS_CHOICES` — Draft / Publish / Pending / Private — and selects the stored `AppSettings.wp_status`) — the "Page" wxStaticBoxSizer over the same grid` + +The Application Password is kept in this machine's settings file for now. A blank parent page publishes at the top level. The site account needs permission to publish pages with inline styles. `(fixed help line, 420)` + +PublishCancel + +`wxID_OK ("Publish", default) · wxID_CANCEL — the stock std sizer and stock ids (§13, UX-DESKTOP §3)` + +⚠ code-side (Phase 4b): Results ▸ Publish to WordPress… (`mi_publish_wordpress`, FINISHED-only) opens this form; `views/publish_wordpress.py`'s `PublishWordpressDialog` seeds the site fields from the live settings and the title/slug from the live ride's name (`default_title`/`default_slug`), collects one `PublishForm` on OK, and only then calls `app._publish_wordpress` — a refused form (`PublishForm.errors`: a blank field, or a site URL not beginning `https://`; the same rule the client's `InsecureURLError` enforces) shows its messages in one native error alert and leaves the dialog open, so the correction is a field edit and nothing is lost. The app persists the five `wp_*` settings first, then renders `htmlexport.render_wordpress` and publishes on a background thread (the console never blocks on the network): the parent page resolves as a numeric id or through `wordpress.find_page_by_slug` (a miss is top level), an existing page at the slug is updated in place rather than duplicated, and success posts "Published to " on the status bar while a `WordPressError` — a refused Application Password, a WordPress error document, an unreachable host — arrives in its own native error alert. The window lives in dialogs.xrc (§15b). diff --git a/noxfile.py b/noxfile.py index 0613104d..b38c4d50 100644 --- a/noxfile.py +++ b/noxfile.py @@ -156,11 +156,13 @@ def userguide_drift(session): @nox.session(python=PYTHON) def gen_css(session): - """Regenerate the vendored compiled_css + fonts_css (E6.2.1). + """Regenerate the three vendored CSS artifacts (E6.2.1). - Needs the pinned Tailwind CLI; :func:`_ensure_node_modules` installs - it with the lockfile's ``npm ci`` on a clean checkout. The nox - ``unit`` session never needs it. + compiled_css, compiled_css_wp (its WordPress-scoped variant, + derived by the same ``--write``) and fonts_css. Needs the pinned + Tailwind CLI; :func:`_ensure_node_modules` installs it with the + lockfile's ``npm ci`` on a clean checkout. The nox ``unit`` session + never needs it. """ _ensure_node_modules(session) session.install(DEV) @@ -172,10 +174,12 @@ def css_drift(session): """Fail if the vendored CSS disagrees with the templates (E6.2.1). This single ``--check`` is both the CI compile and the TB-7 - staleness gate. Passes vacuously until the templates exist, so the - gate can be wired into CI before they are authored. Needs the pinned - Tailwind CLI; :func:`_ensure_node_modules` installs it with the - lockfile's ``npm ci`` on a clean checkout. + staleness gate, for the compiled artifact and for the WordPress- + scoped variant derived from it. Passes vacuously until the + templates exist, so the gate can be wired into CI before they are + authored. Needs the pinned Tailwind CLI; + :func:`_ensure_node_modules` installs it with the lockfile's + ``npm ci`` on a clean checkout. """ if not GEN_CSS.exists() or not any(HTMLEXPORT_TEMPLATES.glob("*.j2")): session.log("no htmlexport templates yet - nothing to check") diff --git a/pyproject.toml b/pyproject.toml index fd071294..9cd5b24e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -85,15 +85,20 @@ rivercrossing = [ "ui/assets/cards/*.png", "ui/assets/sounds/*.wav", "vectors/*.csv", - # E6.2.1: the frozen results templates plus the two vendored CSS + # E6.2.1: the frozen results templates plus the vendored CSS # artifacts. Explicit per-file entries (no glob, no fonts/) -- the # base64 fonts_css ships instead of the woff2 sources. "htmlexport/templates/base.html.j2", "htmlexport/templates/macros.html.j2", # The podium poster's own page (htmlexport.render_poster). "htmlexport/templates/poster.html.j2", + # The WordPress content fragment (htmlexport.render_wordpress) and + # the stylesheet it inlines: compiled_css with every selector the + # fragment renders already scoped under its .rc-results wrapper. + "htmlexport/templates/wordpress.html.j2", "htmlexport/templates/theme.css", "htmlexport/templates/compiled_css", + "htmlexport/templates/compiled_css_wp", "htmlexport/templates/fonts_css", # P7 (E6.3.1): the PDF report's three TTF faces. fpdf2 reads them # from the package at render time and embeds them into the PDF @@ -138,6 +143,11 @@ ignore = [ [tool.ruff.lint.per-file-ignores] # Tests assert with bare `assert` and use literal expected values on purpose. "tests/**" = ["S101", "PLR2004", "INP001", "ANN201", "D100", "D103", "SLF001"] +# The generated frozen-name registry: its constants are the XRC `name` +# attributes, so `WP_PASSWORD_INPUT = "wp_password_input"` names a wx control, +# not a credential (an S105 false positive). It is regenerated wholesale by +# tools/gen_ids.py -- `nox -s gen_ids` -- so an inline noqa would be wiped. +"src/rivercrossing/ui/ids.py" = ["S105"] # Developer tooling is a script, not library code, and prints its progress. # PLC0415: the probes import wx *inside* each function on purpose -- the driver # process must stay importable when wx is broken, which is the case it reports. @@ -264,5 +274,8 @@ source_modules = [ "rivercrossing.csvio", "rivercrossing.standings", "rivercrossing.store", + # The WordPress publish client (topic/wordpress-publish): stdlib + # urllib only, and no wx. + "rivercrossing.wordpress", ] forbidden_modules = ["wx"] diff --git a/src/rivercrossing/htmlexport/__init__.py b/src/rivercrossing/htmlexport/__init__.py index 23e5de81..d1737a20 100644 --- a/src/rivercrossing/htmlexport/__init__.py +++ b/src/rivercrossing/htmlexport/__init__.py @@ -22,7 +22,11 @@ `` Environment: return env -def _template_context( # noqa: PLR0913 -- the five context inputs the template contract names +def _template_context( # noqa: PLR0913 -- the six context inputs the templates name payload: RacePayload, *, dev: bool, logo_src: str | None, generated: str | None, placed: Sequence[Placed] | None = None, + stylesheet: str = "compiled_css", ) -> dict[str, object]: - """Build the ``base.html.j2`` context from *payload* (Spec §8). + """Build one page's template context from *payload* (Spec §8). ``generated`` overrides the payload's own event timestamp in both the footer and the embedded JSON record (D15's freeze seam); @@ -516,6 +522,12 @@ def _template_context( # noqa: PLR0913 -- the five context inputs the template team-size note) still lead with "TEAM", hence the prefix match; the renderer's own rows are exactly "TEAM"/"SOLO" (``_result_row_from_placed``). + + *stylesheet* names the vendored CSS artifact the page inlines: + ``compiled_css`` for the standalone page, ``compiled_css_wp`` for + the WordPress fragment, whose selectors ``tools/gen_css.py`` has + already scoped under the fragment's wrapper. The key carries that + artifact's name, so neither template can inline the other's. """ if generated is not None: payload = replace(payload, event=replace(payload.event, generated=generated)) @@ -530,7 +542,7 @@ def _template_context( # noqa: PLR0913 -- the five context inputs the template "logo_alt": payload.event.organizer, "payload": payload, "dev": dev, - "compiled_css": _asset_text("compiled_css"), + stylesheet: _asset_text(stylesheet), "fonts_css": _asset_text("fonts_css"), } @@ -937,6 +949,86 @@ def render( # noqa: PLR0913 return _render_payload(payload, logo_src=logo_src, placed=placed) +# ======================================== WordPress fragment (R-61) + +# The vendored artifact the fragment inlines: the compiled stylesheet +# with every selector already scoped under the ``.rc-results`` class +# wordpress.html.j2 wraps the fragment in (tools/gen_css.py). Published +# as a page's ``content``, those rules match only inside that wrapper, +# so nothing in them can restyle the site they land in. +_WP_STYLESHEET = "compiled_css_wp" + + +def render_wordpress( # noqa: PLR0913 -- render()'s own signature, for a swap-in call site + ride: _RideLike, + placed: Sequence[Placed], + opts: ExportOptions, + *, + logo_src: str | None = None, + generated: str | None = None, + logo_path: Path | str | None = None, + team_logos: Mapping[str, str] | None = None, + self_test_unverified: bool = False, +) -> str: + """Render one finished ride as a WordPress page-content fragment. + + :func:`render`'s inputs and :func:`render`'s output, in the shape a + WordPress Page's ``content`` takes: the same payload + (:func:`build_payload`), the same :func:`sections` plan and the + same macros, but no document scaffold -- no ````, + ````, ```` or ````, which the publishing site + already has -- and no ``race-data`` record, which nothing on the + site reads. Everything is wrapped in ``
``, + and the fragment inlines ``compiled_css_wp``: the vendored + stylesheet with every selector scoped under that wrapper + (``tools/gen_css.py``), so publishing these results cannot restyle + the site's own theme. + + Callers publish the returned string as-is + (``rivercrossing.wordpress.publish_page(content=…)``). + + Args: + ride: Ride-like object exposing ``name``/``event_date``/ + ``venue``/``lap_km``/``organizer``/``scorer``; + ``RideConfig`` satisfies it structurally. + placed: Ranked standings, one per entry. + opts: Export flags (times/boards/full-field/all-cards). + logo_src: Base64 logo data URI; transparent fallback when None. + generated: Footer timestamp; defaults to now, samples' style. + logo_path: Raw PNG path, base64-embedded when *logo_src* is + None (R-61's logo-base64 rule). + team_logos: Plate -> logo data URI for the entries that carry + one; rows without a mapping render no logo. + self_test_unverified: Whether the ride was finished over a + failed evaluator self-test. + + Returns: + The HTML fragment as a string. + + Raises: + ValueError: An entry's plate is not numeric. + """ + if logo_src is None and logo_path is not None: + logo_src = _logo_data_uri(logo_path) + payload = build_payload( + ride, + placed, + opts, + generated, + team_logos=team_logos, + self_test_unverified=self_test_unverified, + ) + context = _template_context( + payload, + dev=False, + logo_src=logo_src, + generated=generated, + placed=placed, + stylesheet=_WP_STYLESHEET, + ) + return _make_environment().get_template("wordpress.html.j2").render(**context) + + # ============================================ poster renderer ([5d]) # The poster's per-kind card counts, mirroring ``pdfexport._PosterPDF``: diff --git a/src/rivercrossing/htmlexport/templates/compiled_css_wp b/src/rivercrossing/htmlexport/templates/compiled_css_wp new file mode 100644 index 00000000..35bc0fb1 --- /dev/null +++ b/src/rivercrossing/htmlexport/templates/compiled_css_wp @@ -0,0 +1,4 @@ +/* rivercrossing compiled_css_wp — generated by tools/gen_css.py; the WordPress-scoped variant of compiled_css; source theme.css sha256:63284dd6199a60ca2fc070117651a0da6568ce02a670a74f4b07fcac28ef7006; tailwindcss 4.3.3 */ + +/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */ +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){.rc-results *,.rc-results :before,.rc-results :after,.rc-results ::backdrop{--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial}}}.rc-results{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--spacing:.25rem;--container-2xl:42rem;--container-5xl:64rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--font-weight-semibold:600;--font-weight-bold:700;--tracking-wide:.025em;--leading-tight:1.25;--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-ink:#1d1f20;--color-paper:#f2f2f3;--color-steel-300:#b5d9fd;--color-steel-500:#749dc4;--color-steel-600:#597ea3;--color-steel-700:#416180;--color-steel-800:#2c455d;--font-head:"Barlow Condensed", sans-serif;--font-body:"Barlow", sans-serif}.rc-results *,.rc-results :after,.rc-results :before,.rc-results ::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}.rc-results ::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}.rc-results{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}.rc-results hr{height:0;color:inherit;border-top-width:1px}.rc-results abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}.rc-results h1,.rc-results h2,.rc-results h3,.rc-results h4,.rc-results h5,.rc-results h6{font-size:inherit;font-weight:inherit}.rc-results a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}.rc-results b,.rc-results strong{font-weight:bolder}.rc-results code,.rc-results kbd,.rc-results samp,.rc-results pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}.rc-results small{font-size:80%}.rc-results sub,.rc-results sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}.rc-results sub{bottom:-.25em}.rc-results sup{top:-.5em}.rc-results table{text-indent:0;border-color:inherit;border-collapse:collapse}.rc-results :-moz-focusring:where(:not(iframe)){outline:auto}.rc-results progress{vertical-align:baseline}.rc-results summary{display:list-item}.rc-results ol,.rc-results ul,.rc-results menu{list-style:none}.rc-results img,.rc-results svg,.rc-results video,.rc-results canvas,.rc-results audio,.rc-results iframe,.rc-results embed,.rc-results object{vertical-align:middle;display:block}.rc-results img,.rc-results video{max-width:100%;height:auto}.rc-results button,.rc-results input,.rc-results select,.rc-results optgroup,.rc-results textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}.rc-results ::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}.rc-results :where(select:is([multiple],[size])) optgroup{font-weight:bolder}.rc-results :where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}.rc-results ::file-selector-button{margin-inline-end:4px}.rc-results ::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){.rc-results ::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){.rc-results ::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}.rc-results textarea{resize:vertical}.rc-results ::-webkit-search-decoration{-webkit-appearance:none}.rc-results ::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}.rc-results ::-webkit-datetime-edit{display:inline-flex}.rc-results ::-webkit-datetime-edit-fields-wrapper{padding:0}.rc-results ::-webkit-datetime-edit{padding-block:0}.rc-results ::-webkit-datetime-edit-year-field{padding-block:0}.rc-results ::-webkit-datetime-edit-month-field{padding-block:0}.rc-results ::-webkit-datetime-edit-day-field{padding-block:0}.rc-results ::-webkit-datetime-edit-hour-field{padding-block:0}.rc-results ::-webkit-datetime-edit-minute-field{padding-block:0}.rc-results ::-webkit-datetime-edit-second-field{padding-block:0}.rc-results ::-webkit-datetime-edit-millisecond-field{padding-block:0}.rc-results ::-webkit-datetime-edit-meridiem-field{padding-block:0}.rc-results ::-webkit-calendar-picker-indicator{line-height:1}.rc-results :-moz-ui-invalid{box-shadow:none}.rc-results button,.rc-results input:where([type=button],[type=reset],[type=submit]){appearance:button}.rc-results ::file-selector-button{appearance:button}.rc-results ::-webkit-inner-spin-button{height:auto}.rc-results ::-webkit-outer-spin-button{height:auto}.rc-results [hidden]:where(:not([hidden=until-found])){display:none!important}@layer components;.rc-results .static{position:static}.rc-results .mx-auto{margin-inline:auto}.rc-results .ms-1{margin-inline-start:var(--spacing)}.rc-results .ms-auto{margin-inline-start:auto}.rc-results .me-1\.5{margin-inline-end:calc(var(--spacing) * 1.5)}.rc-results .mt-1{margin-top:var(--spacing)}.rc-results .mt-2{margin-top:calc(var(--spacing) * 2)}.rc-results .mt-3{margin-top:calc(var(--spacing) * 3)}.rc-results .mt-4{margin-top:calc(var(--spacing) * 4)}.rc-results .mt-6{margin-top:calc(var(--spacing) * 6)}.rc-results .mt-8{margin-top:calc(var(--spacing) * 8)}.rc-results .mt-10{margin-top:calc(var(--spacing) * 10)}.rc-results .mt-12{margin-top:calc(var(--spacing) * 12)}.rc-results .block{display:block}.rc-results .flex{display:flex}.rc-results .grid{display:grid}.rc-results .hidden{display:none}.rc-results .inline-flex{display:inline-flex}.rc-results .table{display:table}.rc-results .h-5{height:calc(var(--spacing) * 5)}.rc-results .h-16{height:calc(var(--spacing) * 16)}.rc-results .w-8{width:calc(var(--spacing) * 8)}.rc-results .w-12{width:calc(var(--spacing) * 12)}.rc-results .w-20{width:calc(var(--spacing) * 20)}.rc-results .w-auto{width:auto}.rc-results .w-full{width:100%}.rc-results .max-w-2xl{max-width:var(--container-2xl)}.rc-results .max-w-5xl{max-width:var(--container-5xl)}.rc-results .min-w-0{min-width:0}.rc-results .flex-wrap{flex-wrap:wrap}.rc-results .items-baseline{align-items:baseline}.rc-results .items-center{align-items:center}.rc-results .gap-1{gap:var(--spacing)}.rc-results .gap-3{gap:calc(var(--spacing) * 3)}.rc-results .gap-5{gap:calc(var(--spacing) * 5)}.rc-results .gap-6{gap:calc(var(--spacing) * 6)}.rc-results .gap-8{gap:calc(var(--spacing) * 8)}.rc-results .gap-x-8{column-gap:calc(var(--spacing) * 8)}.rc-results .gap-y-2{row-gap:calc(var(--spacing) * 2)}.rc-results .truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.rc-results .overflow-x-auto{overflow-x:auto}.rc-results .border{border-style:var(--tw-border-style);border-width:1px}.rc-results .border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.rc-results .border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.rc-results .border-ink\/8{border-color:#1d1f2014}@supports (color:color-mix(in lab, red, red)){.rc-results .border-ink\/8{border-color:color-mix(in oklab, var(--color-ink) 8%, transparent)}}.rc-results .border-ink\/15{border-color:#1d1f2026}@supports (color:color-mix(in lab, red, red)){.rc-results .border-ink\/15{border-color:color-mix(in oklab, var(--color-ink) 15%, transparent)}}.rc-results .border-ink\/30{border-color:#1d1f204d}@supports (color:color-mix(in lab, red, red)){.rc-results .border-ink\/30{border-color:color-mix(in oklab, var(--color-ink) 30%, transparent)}}.rc-results .border-steel-600{border-color:var(--color-steel-600)}.rc-results .bg-paper{background-color:var(--color-paper)}.rc-results .bg-steel-500\/10{background-color:#749dc41a}@supports (color:color-mix(in lab, red, red)){.rc-results .bg-steel-500\/10{background-color:color-mix(in oklab, var(--color-steel-500) 10%, transparent)}}.rc-results .bg-steel-800{background-color:var(--color-steel-800)}.rc-results .bg-transparent{background-color:#0000}.rc-results .p-5{padding:calc(var(--spacing) * 5)}.rc-results .p-6{padding:calc(var(--spacing) * 6)}.rc-results .px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.rc-results .px-2{padding-inline:calc(var(--spacing) * 2)}.rc-results .px-4{padding-inline:calc(var(--spacing) * 4)}.rc-results .py-0\.5{padding-block:calc(var(--spacing) * .5)}.rc-results .py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.rc-results .py-2{padding-block:calc(var(--spacing) * 2)}.rc-results .py-8{padding-block:calc(var(--spacing) * 8)}.rc-results .pt-0{padding-top:0}.rc-results .pt-5{padding-top:calc(var(--spacing) * 5)}.rc-results .pr-3{padding-right:calc(var(--spacing) * 3)}.rc-results .pb-2{padding-bottom:calc(var(--spacing) * 2)}.rc-results .pb-8{padding-bottom:calc(var(--spacing) * 8)}.rc-results .text-left{text-align:left}.rc-results .text-right{text-align:right}.rc-results .align-middle{vertical-align:middle}.rc-results .font-body{font-family:var(--font-body)}.rc-results .font-head{font-family:var(--font-head)}.rc-results .text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.rc-results .text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.rc-results .text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.rc-results .text-5xl{font-size:var(--text-5xl);line-height:var(--tw-leading,var(--text-5xl--line-height))}.rc-results .text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.rc-results .text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.rc-results .text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.rc-results .text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.rc-results .text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.rc-results .text-\[10px\]{font-size:10px}.rc-results .text-\[11px\]{font-size:11px}.rc-results .leading-7{--tw-leading:calc(var(--spacing) * 7);line-height:calc(var(--spacing) * 7)}.rc-results .leading-none{--tw-leading:1;line-height:1}.rc-results .leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.rc-results .font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.rc-results .font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.rc-results .tracking-\[0\.08em\]{--tw-tracking:.08em;letter-spacing:.08em}.rc-results .tracking-\[0\.12em\]{--tw-tracking:.12em;letter-spacing:.12em}.rc-results .tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.rc-results .text-ink{color:var(--color-ink)}.rc-results .text-ink\/40{color:#1d1f2066}@supports (color:color-mix(in lab, red, red)){.rc-results .text-ink\/40{color:color-mix(in oklab, var(--color-ink) 40%, transparent)}}.rc-results .text-ink\/50{color:#1d1f2080}@supports (color:color-mix(in lab, red, red)){.rc-results .text-ink\/50{color:color-mix(in oklab, var(--color-ink) 50%, transparent)}}.rc-results .text-ink\/55{color:#1d1f208c}@supports (color:color-mix(in lab, red, red)){.rc-results .text-ink\/55{color:color-mix(in oklab, var(--color-ink) 55%, transparent)}}.rc-results .text-ink\/60{color:#1d1f2099}@supports (color:color-mix(in lab, red, red)){.rc-results .text-ink\/60{color:color-mix(in oklab, var(--color-ink) 60%, transparent)}}.rc-results .text-paper{color:var(--color-paper)}.rc-results .text-paper\/70{color:#f2f2f3b3}@supports (color:color-mix(in lab, red, red)){.rc-results .text-paper\/70{color:color-mix(in oklab, var(--color-paper) 70%, transparent)}}.rc-results .text-steel-300{color:var(--color-steel-300)}.rc-results .text-steel-700{color:var(--color-steel-700)}.rc-results .uppercase{text-transform:uppercase}.rc-results .tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.rc-results .opacity-60{opacity:.6}@media (min-width:40rem){.rc-results .sm\:block{display:block}.rc-results .sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.rc-results .sm\:p-8{padding:calc(var(--spacing) * 8)}.rc-results .sm\:px-8{padding-inline:calc(var(--spacing) * 8)}.rc-results .sm\:text-5xl{font-size:var(--text-5xl);line-height:var(--tw-leading,var(--text-5xl--line-height))}}@media (min-width:48rem){.rc-results .md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}.rc-results .bp{border:1px solid #1d1f2029;position:relative}.rc-results .bp>i.c{color:#1d1f208c;width:11px;height:11px;position:absolute}.rc-results .bp>i.c:before,.rc-results .bp>i.c:after{content:"";background:currentColor;position:absolute}.rc-results .bp>i.c:before{width:1px;height:100%;top:0;left:5px}.rc-results .bp>i.c:after{width:100%;height:1px;top:5px;left:0}.rc-results .bp>i.tl{top:-6px;left:-6px}.rc-results .bp>i.tr{top:-6px;right:-6px}.rc-results .bp>i.bl{bottom:-6px;left:-6px}.rc-results .bp>i.br{bottom:-6px;right:-6px}.rc-results .chip{color:#1d1f20;font-family:var(--font-head);background:#e9e9ea;border:1px solid #1d1f2029;align-items:center;gap:2px;padding:0 6px;font-size:13px;font-weight:600;line-height:20px;display:inline-flex}.rc-results .chip.r{color:#416180}.rc-results .chip.j{color:#416180;background:#e9e9ea;border-color:#597ea3}@media print{.rc-results .no-print{display:none!important}.rc-results{background:#fff!important}}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false} \ No newline at end of file diff --git a/src/rivercrossing/htmlexport/templates/wordpress.html.j2 b/src/rivercrossing/htmlexport/templates/wordpress.html.j2 new file mode 100644 index 00000000..6beb3c5e --- /dev/null +++ b/src/rivercrossing/htmlexport/templates/wordpress.html.j2 @@ -0,0 +1,137 @@ +{#- rivercrossing.htmlexport — WordPress content fragment (R-61). + Rendered by htmlexport.render_wordpress(ride, placed, opts) with the same + Environment and macros base.html.j2 uses, and published as a WordPress + Page's `content` over the REST API (rivercrossing.wordpress). + + WHAT THIS FILE IS NOT: a document. A WordPress page already has one, so + there is no /// here — WordPress stores and + prints exactly these bytes inside the page — and no