From 1605fa82652f090dd103dee6e8bf6e96adc2f506 Mon Sep 17 00:00:00 2001 From: Rickard Armiento Date: Fri, 27 Mar 2026 01:12:14 +0000 Subject: [PATCH 01/10] Add migration page --- docs/how_it_works.rst | 2 + docs/index.rst | 2 + docs/migration_legacy_to_jinja2.rst | 191 ++++++++++++++++++++++++++++ 3 files changed, 195 insertions(+) create mode 100644 docs/migration_legacy_to_jinja2.rst diff --git a/docs/how_it_works.rst b/docs/how_it_works.rst index a9cc2b4..775057a 100644 --- a/docs/how_it_works.rst +++ b/docs/how_it_works.rst @@ -99,3 +99,5 @@ Migrated legacy examples are available under ``examples/legacy``: - ``search_app`` For legacy examples that use optional old ``httk`` subsystems, availability depends on those dependencies. + +See also: :doc:`migration_legacy_to_jinja2`. diff --git a/docs/index.rst b/docs/index.rst index da5c84a..fd42c98 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -7,6 +7,7 @@ Quick links ----------- - :doc:`how_it_works` +- :doc:`migration_legacy_to_jinja2` - :doc:`reference` .. toctree:: @@ -14,4 +15,5 @@ Quick links :caption: Documentation how_it_works + migration_legacy_to_jinja2 reference diff --git a/docs/migration_legacy_to_jinja2.rst b/docs/migration_legacy_to_jinja2.rst new file mode 100644 index 0000000..b65a3c1 --- /dev/null +++ b/docs/migration_legacy_to_jinja2.rst @@ -0,0 +1,191 @@ +Migration: Legacy Templates to Jinja2 +===================================== + +This guide maps common legacy template patterns to their modern Jinja2 equivalents. + +Overview +-------- + +Legacy projects typically use ``.httkweb.html`` templates with formatter-style +constructs such as ``{name:repeat::...}``, ``{func:call:...}``, and conditional +specifiers. Modern projects should use ``.html.j2`` (or ``.jinja``/``.j2``) +with standard Jinja2 syntax. + +Template file naming +-------------------- + +Legacy +^^^^^^ + +- ``default.httkweb.html`` +- ``base_default.httkweb.html`` +- function fragments like ``search_result.httkweb.html`` + +Modern +^^^^^^ + +- ``default.html.j2`` +- ``base_default.html.j2`` +- function fragments like ``search_result.html.j2`` + +Variable access +--------------- + +Legacy: + +.. code-block:: text + + {title} + {page.relurl} + +Modern: + +.. code-block:: jinja + + {{ title }} + {{ page.relurl }} + +Escaping behavior +----------------- + +Legacy templates often rely on formatter-level quote/unquoted controls. +In Jinja2, use autoescaping defaults and explicit ``|safe`` only when needed. + +Legacy: + +.. code-block:: text + + {content} + {content:unquoted} + +Modern: + +.. code-block:: jinja + + {{ content }} + {{ content|safe }} + +Loops +----- + +Legacy ``repeat``: + +.. code-block:: text + + {menuitems:repeat:: +
  • {{item}}
  • + } + +Modern Jinja2: + +.. code-block:: jinja + + {% for item in menuitems %} +
  • {{ item }}
  • + {% endfor %} + +Conditionals +------------ + +Legacy conditionals are encoded in format specs (for example ``if`` / ``if-not``). +Use Jinja2 control blocks directly. + +Legacy: + +.. code-block:: text + + {error_404_reason:if:Reason: {error_404_reason}} + +Modern: + +.. code-block:: jinja + + {% if error_404_reason %} + Reason: {{ error_404_reason }} + {% endif %} + +Function-style calls +-------------------- + +Legacy ``call`` often appears around helper access: + +.. code-block:: text + + {{pages:call:{{item}}:title}} + +Modern: + +.. code-block:: jinja + + {{ pages(item, 'title') }} + +Indexing and attribute access +----------------------------- + +Legacy: + +.. code-block:: text + + {{item[id]}} + {{item[formula]}} + +Modern: + +.. code-block:: jinja + + {{ item['id'] }} + {{ item['formula'] }} + +Function fragments (metadata) +----------------------------- + +Legacy metadata often points to fragment names without modern suffixes. +Modern projects should use clear names and Jinja templates. + +Legacy metadata: + +.. code-block:: yaml + + results-function: search:q:search_result + +Modern metadata (same key format, modern template files): + +.. code-block:: yaml + + results-function: search:q:search_result + +and template file: + +.. code-block:: text + + templates/search_result.html.j2 + +Metadata key casing and structure +--------------------------------- + +Prefer lowercase keys in modern projects: + +- ``template`` instead of ``Template`` +- ``base_template`` instead of ``Base_template`` +- ``title`` instead of ``Title`` + +Both styles may work in compatibility contexts, but lowercase metadata avoids +surprises and keeps templates predictable. + +Migration checklist +------------------- + +1. Rename templates to ``.html.j2``. +2. Replace legacy formatter expressions with Jinja2 syntax. +3. Keep helper usage explicit: ``pages(...)``, ``first_value(...)``, ``listdir(...)``. +4. Normalize metadata keys to lowercase. +5. Run your site with ``compatibility_mode=False`` and verify output. + +Tip +--- + +If needed, migrate page-by-page: + +- Keep legacy project operational in compatibility mode. +- Port one template at a time to Jinja2. +- Switch the site to current mode once templates and metadata are fully modernized. From 66b757c5ac71d6b202d5335156a3a51aa281bbb6 Mon Sep 17 00:00:00 2001 From: Rickard Armiento Date: Sat, 28 Mar 2026 09:41:43 +0000 Subject: [PATCH 02/10] Convert docs to markdown; add reference to website template repo --- docs/conf.py | 10 ++ docs/how_it_works.md | 94 +++++++++++++ docs/how_it_works.rst | 103 --------------- docs/index.md | 22 +++ docs/index.rst | 19 --- docs/migration_legacy_to_jinja2.md | 172 ++++++++++++++++++++++++ docs/migration_legacy_to_jinja2.rst | 191 --------------------------- docs/{reference.rst => reference.md} | 16 ++- docs/site_template_repository.md | 44 ++++++ 9 files changed, 352 insertions(+), 319 deletions(-) create mode 100644 docs/how_it_works.md delete mode 100644 docs/how_it_works.rst create mode 100644 docs/index.md delete mode 100644 docs/index.rst create mode 100644 docs/migration_legacy_to_jinja2.md delete mode 100644 docs/migration_legacy_to_jinja2.rst rename docs/{reference.rst => reference.md} (53%) create mode 100644 docs/site_template_repository.md diff --git a/docs/conf.py b/docs/conf.py index 22728e3..2af7207 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -15,6 +15,7 @@ "sphinx.ext.viewcode", "sphinx.ext.intersphinx", "sphinx_copybutton", + "myst_parser", ] templates_path = ["_templates"] @@ -41,3 +42,12 @@ copybutton_prompt_text = r">>> |\.\.\. |\$ " copybutton_prompt_is_regexp = True + +myst_enable_extensions = [ + "colon_fence", + "deflist", + "fieldlist", + "substitution", + "tasklist", +] +myst_heading_anchors = 3 diff --git a/docs/how_it_works.md b/docs/how_it_works.md new file mode 100644 index 0000000..23c16fa --- /dev/null +++ b/docs/how_it_works.md @@ -0,0 +1,94 @@ +# How It Works + +## Current + +The default mode is the current, modern `httk-web` workflow. + +### Directory layout + +A site source directory is expected to contain: + +- `content/`: page sources (`.md`, `.rst`, `.html`) +- `templates/`: Jinja2 templates (for example `default.html.j2` and `base_default.html.j2`) +- `static/`: static files copied as-is in publish mode +- `functions/`: optional Python modules exposing `execute(...)` + +### Runtime flow + +1. Route resolution maps a URL path to a content page or static file. +2. Content rendering extracts metadata and body HTML. +3. Function injection evaluates `*-function` metadata entries when query/post constraints are satisfied. +4. Template rendering produces final HTML through Jinja2. +5. ASGI serving returns responses, or static publishing writes `.html` output files. + +### Public API + +The main API surface is: + +- `httk.web.create_asgi_app(...)` +- `httk.web.serve(...)` +- `httk.web.publish(...)` + +### Example usage + +Serve dynamically: + +```python +from httk.web import serve +serve("src", port=8080) +``` + +Publish statically: + +```python +from httk.web import publish +publish("src", "public", "http://127.0.0.1/") +``` + +### Examples + +Modern examples live under `examples/modern`: + +- `minimal` +- `rst_site` +- `blog` +- `search_app` + +For a ready-made starter repository, see {doc}`site_template_repository`. + +## Legacy + +`httk-web` also supports a compatibility mode for legacy site structures and templates. + +### Enable compatibility mode + +Use `compatibility_mode=True` in API calls: + +```python +from httk.web import serve +serve("src", compatibility_mode=True) +``` + +### Compatibility behaviors + +When compatibility mode is enabled, `httk-web` additionally supports: + +- `.httkweb` content and `.httkweb.html` template resolution +- legacy formatter constructs used by old templates (for example repeat/call/if forms) +- loading global metadata from `config.*` (or another name via `config_name`) +- running `functions/init.py` at engine startup +- `_functions/` directory fallback when `functions/` is not present + +### Examples + +Migrated legacy examples are available under `examples/legacy`: + +- `static_simple` +- `hello_world_app` +- `rst_templator` +- `blog` +- `search_app` + +For legacy examples that use optional old `httk` subsystems, availability depends on those dependencies. + +See also: {doc}`migration_legacy_to_jinja2`. diff --git a/docs/how_it_works.rst b/docs/how_it_works.rst deleted file mode 100644 index 775057a..0000000 --- a/docs/how_it_works.rst +++ /dev/null @@ -1,103 +0,0 @@ -How It Works -============ - -Current -------- - -The default mode is the current, modern ``httk-web`` workflow. - -Directory layout -^^^^^^^^^^^^^^^^ - -A site source directory is expected to contain: - -- ``content/``: page sources (``.md``, ``.rst``, ``.html``) -- ``templates/``: Jinja2 templates (for example ``default.html.j2`` and ``base_default.html.j2``) -- ``static/``: static files copied as-is in publish mode -- ``functions/``: optional Python modules exposing ``execute(...)`` - -Runtime flow -^^^^^^^^^^^^ - -1. Route resolution maps a URL path to a content page or static file. -2. Content rendering extracts metadata and body HTML. -3. Function injection evaluates ``*-function`` metadata entries when query/post constraints are satisfied. -4. Template rendering produces final HTML through Jinja2. -5. ASGI serving returns responses, or static publishing writes ``.html`` output files. - -Public API -^^^^^^^^^^ - -The main API surface is: - -- ``httk.web.create_asgi_app(...)`` -- ``httk.web.serve(...)`` -- ``httk.web.publish(...)`` - -Example usage -^^^^^^^^^^^^^ - -Serve dynamically: - -.. code-block:: python - - from httk.web import serve - serve("src", port=8080) - -Publish statically: - -.. code-block:: python - - from httk.web import publish - publish("src", "public", "http://127.0.0.1/") - -Examples -^^^^^^^^ - -Modern examples live under ``examples/modern``: - -- ``minimal`` -- ``rst_site`` -- ``blog`` -- ``search_app`` - -Legacy ------- - -``httk-web`` also supports a compatibility mode for legacy site structures and templates. - -Enable compatibility mode -^^^^^^^^^^^^^^^^^^^^^^^^^ - -Use ``compatibility_mode=True`` in API calls: - -.. code-block:: python - - from httk.web import serve - serve("src", compatibility_mode=True) - -Compatibility behaviors -^^^^^^^^^^^^^^^^^^^^^^^ - -When compatibility mode is enabled, ``httk-web`` additionally supports: - -- ``.httkweb`` content and ``.httkweb.html`` template resolution -- legacy formatter constructs used by old templates (for example repeat/call/if forms) -- loading global metadata from ``config.*`` (or another name via ``config_name``) -- running ``functions/init.py`` at engine startup -- ``_functions/`` directory fallback when ``functions/`` is not present - -Examples -^^^^^^^^ - -Migrated legacy examples are available under ``examples/legacy``: - -- ``static_simple`` -- ``hello_world_app`` -- ``rst_templator`` -- ``blog`` -- ``search_app`` - -For legacy examples that use optional old ``httk`` subsystems, availability depends on those dependencies. - -See also: :doc:`migration_legacy_to_jinja2`. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..41a4d41 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,22 @@ +# `httk-web` + +`httk-web` provides web serving and static publishing functionality for `httk` v2. + +```{admonition} Quick links +:class: tip + +- {doc}`how_it_works` +- {doc}`migration_legacy_to_jinja2` +- {doc}`site_template_repository` +- {doc}`reference` +``` + +```{toctree} +:maxdepth: 2 +:caption: Documentation + +how_it_works +migration_legacy_to_jinja2 +site_template_repository +reference +``` diff --git a/docs/index.rst b/docs/index.rst deleted file mode 100644 index fd42c98..0000000 --- a/docs/index.rst +++ /dev/null @@ -1,19 +0,0 @@ -httk-web -======== - -``httk-web`` provides web serving and static publishing functionality for ``httk`` v2. - -Quick links ------------ - -- :doc:`how_it_works` -- :doc:`migration_legacy_to_jinja2` -- :doc:`reference` - -.. toctree:: - :maxdepth: 2 - :caption: Documentation - - how_it_works - migration_legacy_to_jinja2 - reference diff --git a/docs/migration_legacy_to_jinja2.md b/docs/migration_legacy_to_jinja2.md new file mode 100644 index 0000000..b6133b1 --- /dev/null +++ b/docs/migration_legacy_to_jinja2.md @@ -0,0 +1,172 @@ +# Migration: Legacy Templates to Jinja2 + +This guide maps common legacy template patterns to their modern Jinja2 equivalents. + +## Overview + +Legacy projects typically use `.httkweb.html` templates with formatter-style constructs such as `{name:repeat::...}`, `{func:call:...}`, and conditional specifiers. Modern projects should use `.html.j2` (or `.jinja`/`.j2`) with standard Jinja2 syntax. + +## Template file naming + +### Legacy + +- `default.httkweb.html` +- `base_default.httkweb.html` +- function fragments like `search_result.httkweb.html` + +### Modern + +- `default.html.j2` +- `base_default.html.j2` +- function fragments like `search_result.html.j2` + +## Variable access + +Legacy: + +```text +{title} +{page.relurl} +``` + +Modern: + +```jinja +{{ title }} +{{ page.relurl }} +``` + +## Escaping behavior + +Legacy templates often rely on formatter-level quote/unquoted controls. +In Jinja2, use autoescaping defaults and explicit `|safe` only when needed. + +Legacy: + +```text +{content} +{content:unquoted} +``` + +Modern: + +```jinja +{{ content }} +{{ content|safe }} +``` + +## Loops + +Legacy `repeat`: + +```text +{menuitems:repeat:: +
  • {{item}}
  • +} +``` + +Modern Jinja2: + +```jinja +{% for item in menuitems %} +
  • {{ item }}
  • +{% endfor %} +``` + +## Conditionals + +Legacy conditionals are encoded in format specs (for example `if` / `if-not`). +Use Jinja2 control blocks directly. + +Legacy: + +```text +{error_404_reason:if:Reason: {error_404_reason}} +``` + +Modern: + +```jinja +{% if error_404_reason %} +Reason: {{ error_404_reason }} +{% endif %} +``` + +## Function-style calls + +Legacy `call` often appears around helper access: + +```text +{{pages:call:{{item}}:title}} +``` + +Modern: + +```jinja +{{ pages(item, 'title') }} +``` + +## Indexing and attribute access + +Legacy: + +```text +{{item[id]}} +{{item[formula]}} +``` + +Modern: + +```jinja +{{ item['id'] }} +{{ item['formula'] }} +``` + +## Function fragments (metadata) + +Legacy metadata often points to fragment names without modern suffixes. +Modern projects should use clear names and Jinja templates. + +Legacy metadata: + +```yaml +results-function: search:q:search_result +``` + +Modern metadata (same key format, modern template files): + +```yaml +results-function: search:q:search_result +``` + +and template file: + +```text +templates/search_result.html.j2 +``` + +## Metadata key casing and structure + +Prefer lowercase keys in modern projects: + +- `template` instead of `Template` +- `base_template` instead of `Base_template` +- `title` instead of `Title` + +Both styles may work in compatibility contexts, but lowercase metadata avoids surprises and keeps templates predictable. + +## Migration checklist + +1. Rename templates to `.html.j2`. +2. Replace legacy formatter expressions with Jinja2 syntax. +3. Keep helper usage explicit: `pages(...)`, `first_value(...)`, `listdir(...)`. +4. Normalize metadata keys to lowercase. +5. Run your site with `compatibility_mode=False` and verify output. + +## Tip + +If needed, migrate page-by-page: + +- Keep legacy project operational in compatibility mode. +- Port one template at a time to Jinja2. +- Switch the site to current mode once templates and metadata are fully modernized. diff --git a/docs/migration_legacy_to_jinja2.rst b/docs/migration_legacy_to_jinja2.rst deleted file mode 100644 index b65a3c1..0000000 --- a/docs/migration_legacy_to_jinja2.rst +++ /dev/null @@ -1,191 +0,0 @@ -Migration: Legacy Templates to Jinja2 -===================================== - -This guide maps common legacy template patterns to their modern Jinja2 equivalents. - -Overview --------- - -Legacy projects typically use ``.httkweb.html`` templates with formatter-style -constructs such as ``{name:repeat::...}``, ``{func:call:...}``, and conditional -specifiers. Modern projects should use ``.html.j2`` (or ``.jinja``/``.j2``) -with standard Jinja2 syntax. - -Template file naming --------------------- - -Legacy -^^^^^^ - -- ``default.httkweb.html`` -- ``base_default.httkweb.html`` -- function fragments like ``search_result.httkweb.html`` - -Modern -^^^^^^ - -- ``default.html.j2`` -- ``base_default.html.j2`` -- function fragments like ``search_result.html.j2`` - -Variable access ---------------- - -Legacy: - -.. code-block:: text - - {title} - {page.relurl} - -Modern: - -.. code-block:: jinja - - {{ title }} - {{ page.relurl }} - -Escaping behavior ------------------ - -Legacy templates often rely on formatter-level quote/unquoted controls. -In Jinja2, use autoescaping defaults and explicit ``|safe`` only when needed. - -Legacy: - -.. code-block:: text - - {content} - {content:unquoted} - -Modern: - -.. code-block:: jinja - - {{ content }} - {{ content|safe }} - -Loops ------ - -Legacy ``repeat``: - -.. code-block:: text - - {menuitems:repeat:: -
  • {{item}}
  • - } - -Modern Jinja2: - -.. code-block:: jinja - - {% for item in menuitems %} -
  • {{ item }}
  • - {% endfor %} - -Conditionals ------------- - -Legacy conditionals are encoded in format specs (for example ``if`` / ``if-not``). -Use Jinja2 control blocks directly. - -Legacy: - -.. code-block:: text - - {error_404_reason:if:Reason: {error_404_reason}} - -Modern: - -.. code-block:: jinja - - {% if error_404_reason %} - Reason: {{ error_404_reason }} - {% endif %} - -Function-style calls --------------------- - -Legacy ``call`` often appears around helper access: - -.. code-block:: text - - {{pages:call:{{item}}:title}} - -Modern: - -.. code-block:: jinja - - {{ pages(item, 'title') }} - -Indexing and attribute access ------------------------------ - -Legacy: - -.. code-block:: text - - {{item[id]}} - {{item[formula]}} - -Modern: - -.. code-block:: jinja - - {{ item['id'] }} - {{ item['formula'] }} - -Function fragments (metadata) ------------------------------ - -Legacy metadata often points to fragment names without modern suffixes. -Modern projects should use clear names and Jinja templates. - -Legacy metadata: - -.. code-block:: yaml - - results-function: search:q:search_result - -Modern metadata (same key format, modern template files): - -.. code-block:: yaml - - results-function: search:q:search_result - -and template file: - -.. code-block:: text - - templates/search_result.html.j2 - -Metadata key casing and structure ---------------------------------- - -Prefer lowercase keys in modern projects: - -- ``template`` instead of ``Template`` -- ``base_template`` instead of ``Base_template`` -- ``title`` instead of ``Title`` - -Both styles may work in compatibility contexts, but lowercase metadata avoids -surprises and keeps templates predictable. - -Migration checklist -------------------- - -1. Rename templates to ``.html.j2``. -2. Replace legacy formatter expressions with Jinja2 syntax. -3. Keep helper usage explicit: ``pages(...)``, ``first_value(...)``, ``listdir(...)``. -4. Normalize metadata keys to lowercase. -5. Run your site with ``compatibility_mode=False`` and verify output. - -Tip ---- - -If needed, migrate page-by-page: - -- Keep legacy project operational in compatibility mode. -- Port one template at a time to Jinja2. -- Switch the site to current mode once templates and metadata are fully modernized. diff --git a/docs/reference.rst b/docs/reference.md similarity index 53% rename from docs/reference.rst rename to docs/reference.md index aac4154..3c995da 100644 --- a/docs/reference.rst +++ b/docs/reference.md @@ -1,18 +1,22 @@ -Reference -========= +:orphan: +# Reference -Public API ----------- +This section documents the supported public API. +## Public API + +```{eval-rst} .. automodule:: httk.web.api :members: :undoc-members: :show-inheritance: +``` -Convenience Exports -------------------- +## Convenience Exports +```{eval-rst} .. automodule:: httk.web :members: :undoc-members: :show-inheritance: +``` diff --git a/docs/site_template_repository.md b/docs/site_template_repository.md new file mode 100644 index 0000000..e250107 --- /dev/null +++ b/docs/site_template_repository.md @@ -0,0 +1,44 @@ +# Site Template Repository + +A ready-to-use template repository for new `httk.web` sites is available at: + +- https://github.com/httk/example_website_httk + +## Create a new site on GitHub + +1. Open the template repository URL in your browser. +2. Click **Use this template** (top-right on GitHub). +3. Choose **Create a new repository**. +4. Set repository name/visibility and create it. + +## Clone and run locally + +After creating your repository, clone it and run: + +```bash +git clone https://github.com//.git +cd +python -m pip install -e . +make serve +``` + +## Edit site content + +The main content is under `src/content`: + +- Edit existing pages like `src/content/index.md` and `src/content/contact.md`. +- Add new pages by creating `.md` files in `src/content`. +- Blog posts are in `src/content/blogposts`. + +Then regenerate/publish: + +```bash +make generate +``` + +This writes output to `public/`. + +## GitHub Pages publishing + +The template repository includes a GitHub Actions workflow for Pages publishing. +After enabling GitHub Pages for the repository, pushes to `main` will build and publish the site automatically. From e35c5fac742501191ae93f49f165b9bfc7b43882 Mon Sep 17 00:00:00 2001 From: Rickard Armiento Date: Sat, 28 Mar 2026 10:19:19 +0000 Subject: [PATCH 03/10] Improve handling of links with and without extensions in static generator --- docs/how_it_works.md | 7 ++ docs/site_template_repository.md | 3 + src/httk/web/api.py | 7 ++ src/httk/web/engine/site_engine.py | 129 +++++++++++++++++++++++++++-- src/httk/web/model/config.py | 3 + tests/test_api.py | 67 +++++++++++++++ 6 files changed, 208 insertions(+), 8 deletions(-) diff --git a/docs/how_it_works.md b/docs/how_it_works.md index 23c16fa..8991186 100644 --- a/docs/how_it_works.md +++ b/docs/how_it_works.md @@ -45,6 +45,13 @@ from httk.web import publish publish("src", "public", "http://127.0.0.1/") ``` +To control link style in published output: + +```python +publish("src", "public", "http://127.0.0.1/", use_urls_without_ext=False) # -> /about.html +publish("src", "public", "http://127.0.0.1/", use_urls_without_ext=True) # -> /about +``` + ### Examples Modern examples live under `examples/modern`: diff --git a/docs/site_template_repository.md b/docs/site_template_repository.md index e250107..a9a4487 100644 --- a/docs/site_template_repository.md +++ b/docs/site_template_repository.md @@ -38,6 +38,9 @@ make generate This writes output to `public/`. +If you need `.html` links for a static host, set +`use_urls_without_ext=False` in `publish_static.py`. + ## GitHub Pages publishing The template repository includes a GitHub Actions workflow for Pages publishing. diff --git a/src/httk/web/api.py b/src/httk/web/api.py index 0b5472c..bfa0a8c 100644 --- a/src/httk/web/api.py +++ b/src/httk/web/api.py @@ -16,6 +16,7 @@ def create_asgi_app( baseurl: str | None = None, compatibility_mode: bool = False, config_name: str = "config", + publish_use_urls_without_ext: bool = True, debug: bool = False, ) -> Starlette: config = SiteConfig.from_srcdir( @@ -23,6 +24,7 @@ def create_asgi_app( baseurl=baseurl, compatibility_mode=compatibility_mode, config_name=config_name, + publish_use_urls_without_ext=publish_use_urls_without_ext, ) engine = SiteEngine(config) return create_app(engine=engine, debug=debug) @@ -36,6 +38,7 @@ def serve( baseurl: str | None = None, compatibility_mode: bool = False, config_name: str = "config", + publish_use_urls_without_ext: bool = True, debug: bool = False, ) -> None: app = create_asgi_app( @@ -43,6 +46,7 @@ def serve( baseurl=baseurl, compatibility_mode=compatibility_mode, config_name=config_name, + publish_use_urls_without_ext=publish_use_urls_without_ext, debug=debug, ) run_dev_server(app=app, host=host, port=port) @@ -55,12 +59,15 @@ def publish( *, compatibility_mode: bool = False, config_name: str = "config", + use_urls_without_ext: bool | None = None, ) -> PublishReport: + publish_use_urls_without_ext = use_urls_without_ext if use_urls_without_ext is not None else not compatibility_mode config = SiteConfig.from_srcdir( srcdir=srcdir, baseurl=baseurl, compatibility_mode=compatibility_mode, config_name=config_name, + publish_use_urls_without_ext=publish_use_urls_without_ext, ) engine = SiteEngine(config) return publish_site(engine=engine, outdir=outdir) diff --git a/src/httk/web/engine/site_engine.py b/src/httk/web/engine/site_engine.py index 5537234..71018db 100644 --- a/src/httk/web/engine/site_engine.py +++ b/src/httk/web/engine/site_engine.py @@ -1,4 +1,7 @@ +import posixpath +import re from mimetypes import guess_type +from urllib.parse import SplitResult, urlsplit, urlunsplit from markupsafe import Markup @@ -85,6 +88,7 @@ def render( query=query_params, postvars=postvars, request=request_context, + render_mode=render_mode, ) self._apply_function_injections( metadata=metadata, @@ -102,6 +106,8 @@ def render( context=context, ) ) + if render_mode == "publish": + content_html = self._rewrite_publish_links(content_html, route_key=route_key) return PageResult( status_code=200, @@ -131,6 +137,7 @@ def _build_template_context( query: dict[str, str], postvars: dict[str, str], request: HttpRequestContext, + render_mode: str, ) -> dict[str, object]: context: dict[str, object] = dict(self.global_data) context.update(metadata) @@ -188,7 +195,7 @@ def pages(path: str, field: str) -> object: if field in {"content", "html"}: return page_html if field == "relurl": - return normalized + return self._route_url_path(normalized, render_mode=render_mode) return None context["first_value"] = first_value @@ -204,10 +211,10 @@ def pages(path: str, field: str) -> object: } page_data.update( { - "relurl": route_key, - "absurl": self._absolute_url(route_key), + "relurl": self._route_url_path(route_key, render_mode=render_mode), + "absurl": self._absolute_url(route_key, render_mode=render_mode), "relbaseurl": self._relative_base(route_key), - "functionurl": self._absolute_url(route_key), + "functionurl": self._absolute_url(route_key, render_mode=render_mode), } ) context["page"] = page_data @@ -287,13 +294,14 @@ def _function_args_satisfied(self, arg_specs: list[str], query: dict[str, str]) return False return True - def _absolute_url(self, route_key: str) -> str: + def _absolute_url(self, route_key: str, *, render_mode: str) -> str: + route_path = self._route_url_path(route_key, render_mode=render_mode) if self.config.baseurl is None: - return route_key + return route_path base = self.config.baseurl if not base.endswith("/"): base += "/" - return f"{base}{route_key}" + return f"{base}{route_path}" def _relative_base(self, route_key: str) -> str: depth = max(0, route_key.count("/")) @@ -367,7 +375,7 @@ def _global_pages_helper(self, path: str, field: str) -> object: if field in {"content", "html"}: return page_html if field == "relurl": - return normalize_route(path) + return self._route_url_path(normalize_route(path), render_mode="publish") return None def _metadata_field_value(self, metadata: dict[str, object], field: str) -> object: @@ -377,3 +385,108 @@ def _metadata_field_value(self, metadata: dict[str, object], field: str) -> obje if isinstance(key, str) and key.lower() == field.lower(): return value return None + + def _route_url_path(self, route_key: str, *, render_mode: str) -> str: + if render_mode == "publish" and not self.config.publish_use_urls_without_ext: + return f"{route_key}.html" + return route_key + + _URL_ATTR_PATTERN = re.compile(r"(?P\b(?:href|src)\s*=\s*)(?P['\"])(?P.*?)(?P=quote)") + _ASSET_EXTENSIONS = { + ".css", + ".js", + ".json", + ".txt", + ".xml", + ".png", + ".jpg", + ".jpeg", + ".gif", + ".svg", + ".ico", + ".webp", + ".avif", + ".pdf", + ".zip", + ".tar", + ".gz", + ".mp4", + ".webm", + ".mp3", + ".wav", + } + + def _rewrite_publish_links(self, html: str, *, route_key: str) -> str: + if self.config.publish_use_urls_without_ext: + return html + + def replace(match: re.Match[str]) -> str: + original_url = match.group("url") + rewritten_url = self._rewrite_internal_url(original_url, route_key=route_key) + if rewritten_url is None: + return match.group(0) + return f"{match.group('prefix')}{match.group('quote')}{rewritten_url}{match.group('quote')}" + + return self._URL_ATTR_PATTERN.sub(replace, html) + + def _rewrite_internal_url(self, url: str, *, route_key: str) -> str | None: + if not url or url.startswith("#") or url.startswith("//"): + return None + + parts = urlsplit(url) + if parts.scheme or parts.netloc: + return None + + if not parts.path: + return None + + # Keep clearly non-page assets untouched. + if parts.path.endswith("/"): + path_ext = "" + else: + path_ext = posixpath.splitext(parts.path)[1].lower() + if path_ext and path_ext not in {"", ".html"} and path_ext in self._ASSET_EXTENSIONS: + return None + + candidate_route = self._candidate_route_from_link_path(parts.path, route_key=route_key) + if candidate_route is None: + return None + + resolved = self.resolve(candidate_route) + if resolved.kind != "content" or resolved.source_path is None: + return None + + target_path = self._route_url_path(candidate_route, render_mode="publish") + rewritten_path = self._format_rewritten_path(parts.path, route_key=route_key, target_path=target_path) + rewritten = SplitResult(parts.scheme, parts.netloc, rewritten_path, parts.query, parts.fragment) + return urlunsplit(rewritten) + + def _candidate_route_from_link_path(self, path: str, *, route_key: str) -> str | None: + if not path: + return None + + current_dir = posixpath.dirname(route_key) + if path.startswith("/"): + joined = posixpath.normpath(path.lstrip("/")) + else: + base = current_dir if current_dir else "." + joined = posixpath.normpath(posixpath.join(base, path)) + + if joined.startswith("../"): + return None + + if joined.endswith(".html"): + joined = joined[: -len(".html")] + + return normalize_route(joined) + + def _format_rewritten_path(self, original_path: str, *, route_key: str, target_path: str) -> str: + if original_path.startswith("/"): + return f"/{target_path}" + + current_dir = posixpath.dirname(route_key) + start = current_dir if current_dir else "." + rel = posixpath.relpath(target_path, start=start) + if rel == ".": + return target_path + return rel diff --git a/src/httk/web/model/config.py b/src/httk/web/model/config.py index 221aa10..73ebc11 100644 --- a/src/httk/web/model/config.py +++ b/src/httk/web/model/config.py @@ -13,6 +13,7 @@ class SiteConfig: baseurl: str | None = None compatibility_mode: bool = False config_name: str = "config" + publish_use_urls_without_ext: bool = True @classmethod def from_srcdir( @@ -22,12 +23,14 @@ def from_srcdir( baseurl: str | None = None, compatibility_mode: bool = False, config_name: str = "config", + publish_use_urls_without_ext: bool = True, ) -> Self: return cls( srcdir=Path(srcdir).resolve(), baseurl=baseurl, compatibility_mode=compatibility_mode, config_name=config_name, + publish_use_urls_without_ext=publish_use_urls_without_ext, ) @property diff --git a/tests/test_api.py b/tests/test_api.py index 1caf399..f2a3468 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -36,6 +36,73 @@ def test_publish_writes_html_output(tmp_path: Path) -> None: assert any(path == index_out for path in report.written_files) +def test_publish_uses_extensionless_links_by_default_for_modern_mode(tmp_path: Path) -> None: + src = tmp_path / "src" + out = tmp_path / "public" + (src / "content").mkdir(parents=True) + (src / "static").mkdir(parents=True) + (src / "templates").mkdir(parents=True) + + (src / "content" / "about.md").write_text("---\ntitle: About\n---\n\nabout", encoding="utf-8") + (src / "content" / "index.md").write_text("---\ntemplate: default\n---\n\nhome", encoding="utf-8") + (src / "templates" / "default.html.j2").write_text( + "about|{{ page.relurl }}", + encoding="utf-8", + ) + (src / "templates" / "base_default.html.j2").write_text("{{ content }}", encoding="utf-8") + + publish(src, out, "http://localhost/") + + rendered = (out / "index.html").read_text(encoding="utf-8") + assert "href='about'" in rendered + assert "|index" in rendered + + +def test_publish_can_add_html_suffix_to_links(tmp_path: Path) -> None: + src = tmp_path / "src" + out = tmp_path / "public" + (src / "content").mkdir(parents=True) + (src / "static").mkdir(parents=True) + (src / "templates").mkdir(parents=True) + + (src / "content" / "about.md").write_text("---\ntitle: About\n---\n\nabout", encoding="utf-8") + (src / "content" / "index.md").write_text("---\ntemplate: default\n---\n\nhome", encoding="utf-8") + (src / "templates" / "default.html.j2").write_text( + "about|{{ page.relurl }}", + encoding="utf-8", + ) + (src / "templates" / "base_default.html.j2").write_text("{{ content }}", encoding="utf-8") + + publish(src, out, "http://localhost/", use_urls_without_ext=False) + + rendered = (out / "index.html").read_text(encoding="utf-8") + assert "href='about.html'" in rendered + assert "|index.html" in rendered + + +def test_publish_rewrites_markdown_internal_links_with_html_suffix(tmp_path: Path) -> None: + src = tmp_path / "src" + out = tmp_path / "public" + (src / "content").mkdir(parents=True) + (src / "static").mkdir(parents=True) + (src / "templates").mkdir(parents=True) + + (src / "content" / "about.md").write_text("---\ntitle: About\n---\n\nabout", encoding="utf-8") + (src / "content" / "index.md").write_text( + "---\ntemplate: default\n---\n\n[About](about)\n[Query](about?x=1#top)\n[External](https://example.com)\n", + encoding="utf-8", + ) + (src / "templates" / "default.html.j2").write_text("{{ content }}", encoding="utf-8") + (src / "templates" / "base_default.html.j2").write_text("{{ content }}", encoding="utf-8") + + publish(src, out, "http://localhost/", use_urls_without_ext=False) + + rendered = (out / "index.html").read_text(encoding="utf-8") + assert 'href="about.html"' in rendered + assert 'href="about.html?x=1#top"' in rendered + assert 'href="https://example.com"' in rendered + + def test_create_asgi_app_compatibility_mode_prefers_httkweb_templates(tmp_path: Path) -> None: src = tmp_path / "src" (src / "content").mkdir(parents=True) From 1647e286295e58dc8422a37d5d4843a6fee8b99e Mon Sep 17 00:00:00 2001 From: Rickard Armiento Date: Sat, 28 Mar 2026 10:27:29 +0000 Subject: [PATCH 04/10] Fix migration guide example error --- docs/migration_legacy_to_jinja2.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/migration_legacy_to_jinja2.md b/docs/migration_legacy_to_jinja2.md index b6133b1..bfdefbe 100644 --- a/docs/migration_legacy_to_jinja2.md +++ b/docs/migration_legacy_to_jinja2.md @@ -81,7 +81,7 @@ Use Jinja2 control blocks directly. Legacy: ```text -{error_404_reason:if:Reason: {error_404_reason}} +{error_404_reason:if::Reason: {error_404_reason}} ``` Modern: From 1a7ebd900804894f101e76d3b7e3babad62ef829 Mon Sep 17 00:00:00 2001 From: Rickard Armiento Date: Sat, 28 Mar 2026 10:44:48 +0000 Subject: [PATCH 05/10] Harden publish link rewriting and remove no-op serve URL suffix option --- docs/index.md | 2 +- docs/index.rst | 17 ----------- examples/legacy/blog/src/content/index.md | 2 +- .../src/templates/base_default.httkweb.html | 10 +++++++ src/httk/web/api.py | 4 --- src/httk/web/engine/site_engine.py | 16 ++++++++-- tests/test_api.py | 29 +++++++++++++++++++ 7 files changed, 54 insertions(+), 26 deletions(-) delete mode 100644 docs/index.rst diff --git a/docs/index.md b/docs/index.md index 4846015..a6e2d13 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,5 +1,5 @@ # *httk-web* - + *httk-web* is a [*httk v2*](https://github.com/httk/httk2) module providing web serving and static publishing functionality. ```{admonition} Quick links diff --git a/docs/index.rst b/docs/index.rst deleted file mode 100644 index d95dd69..0000000 --- a/docs/index.rst +++ /dev/null @@ -1,17 +0,0 @@ -httk-web -======== - -*httk-web* is a [*httk v2*](https://github.com/httk/httk2) module providing web serving and static publishing functionality. - -Quick links ------------ - -- :doc:`how_it_works` -- :doc:`reference` - -.. toctree:: - :maxdepth: 2 - :caption: Documentation - - how_it_works - reference diff --git a/examples/legacy/blog/src/content/index.md b/examples/legacy/blog/src/content/index.md index 702570e..ee48dd5 100644 --- a/examples/legacy/blog/src/content/index.md +++ b/examples/legacy/blog/src/content/index.md @@ -21,7 +21,7 @@ Subheading Here is some nice math: -\(\int (x+y) dx\) +$\int (x+y)\,dx$ And a code segment: diff --git a/examples/legacy/blog/src/templates/base_default.httkweb.html b/examples/legacy/blog/src/templates/base_default.httkweb.html index a8f839b..d347bc0 100644 --- a/examples/legacy/blog/src/templates/base_default.httkweb.html +++ b/examples/legacy/blog/src/templates/base_default.httkweb.html @@ -31,9 +31,19 @@ + " in rendered + + def test_create_asgi_app_compatibility_mode_prefers_httkweb_templates(tmp_path: Path) -> None: src = tmp_path / "src" (src / "content").mkdir(parents=True) From ef8be0233bdd46efe2a382f3ea4143bbef3fe589 Mon Sep 17 00:00:00 2001 From: Rickard Armiento Date: Sat, 28 Mar 2026 10:57:25 +0000 Subject: [PATCH 06/10] Fix publish URL suffix normalization, cache link route resolution, and clarify template repo docs --- docs/site_template_repository.md | 4 ++-- src/httk/web/engine/site_engine.py | 24 ++++++++++++++++++++---- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/docs/site_template_repository.md b/docs/site_template_repository.md index a9a4487..dad98b8 100644 --- a/docs/site_template_repository.md +++ b/docs/site_template_repository.md @@ -38,8 +38,8 @@ make generate This writes output to `public/`. -If you need `.html` links for a static host, set -`use_urls_without_ext=False` in `publish_static.py`. +If you need `.html` links for a static host, pass `use_urls_without_ext=False` +to `httk.web.publish(...)` in your publish script. ## GitHub Pages publishing diff --git a/src/httk/web/engine/site_engine.py b/src/httk/web/engine/site_engine.py index ac01db9..8bf5133 100644 --- a/src/httk/web/engine/site_engine.py +++ b/src/httk/web/engine/site_engine.py @@ -388,6 +388,8 @@ def _metadata_field_value(self, metadata: dict[str, object], field: str) -> obje def _route_url_path(self, route_key: str, *, render_mode: str) -> str: if render_mode == "publish" and not self.config.publish_use_urls_without_ext: + if route_key.endswith(".html"): + return route_key return f"{route_key}.html" return route_key @@ -423,9 +425,13 @@ def _rewrite_publish_links(self, html: str, *, route_key: str) -> str: if self.config.publish_use_urls_without_ext: return html + route_exists_cache: dict[str, bool] = {} + def replace_attr(match: re.Match[str]) -> str: original_url = match.group("url") - rewritten_url = self._rewrite_internal_url(original_url, route_key=route_key) + rewritten_url = self._rewrite_internal_url( + original_url, route_key=route_key, route_exists_cache=route_exists_cache + ) if rewritten_url is None: return match.group(0) return f"{match.group('prefix')}{match.group('quote')}{rewritten_url}{match.group('quote')}" @@ -439,7 +445,13 @@ def replace_tag(match: re.Match[str]) -> str: return self._HTML_TAG_PATTERN.sub(replace_tag, html) - def _rewrite_internal_url(self, url: str, *, route_key: str) -> str | None: + def _rewrite_internal_url( + self, + url: str, + *, + route_key: str, + route_exists_cache: dict[str, bool], + ) -> str | None: if not url or url.startswith("#") or url.startswith("//"): return None @@ -462,8 +474,12 @@ def _rewrite_internal_url(self, url: str, *, route_key: str) -> str | None: if candidate_route is None: return None - resolved = self.resolve(candidate_route) - if resolved.kind != "content" or resolved.source_path is None: + is_content_route = route_exists_cache.get(candidate_route) + if is_content_route is None: + resolved = self.resolve(candidate_route) + is_content_route = resolved.kind == "content" and resolved.source_path is not None + route_exists_cache[candidate_route] = is_content_route + if not is_content_route: return None target_path = self._route_url_path(candidate_route, render_mode="publish") From 066daec1d2b49471be93a6c9a25bf3e8de3328c9 Mon Sep 17 00:00:00 2001 From: Rickard Armiento Date: Sat, 28 Mar 2026 11:40:50 +0000 Subject: [PATCH 07/10] Add compatibility-mode default link test and harden HTML tag parsing for link rewrite --- src/httk/web/engine/site_engine.py | 2 +- tests/test_api.py | 25 ++++++++++++++++++++++++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/httk/web/engine/site_engine.py b/src/httk/web/engine/site_engine.py index 8bf5133..2a62496 100644 --- a/src/httk/web/engine/site_engine.py +++ b/src/httk/web/engine/site_engine.py @@ -393,7 +393,7 @@ def _route_url_path(self, route_key: str, *, render_mode: str) -> str: return f"{route_key}.html" return route_key - _HTML_TAG_PATTERN = re.compile(r"<[^>]+>") + _HTML_TAG_PATTERN = re.compile(r"""<(?:[^<>"']+|"[^"]*"|'[^']*')+>""") _URL_ATTR_PATTERN = re.compile( r"(?:(?<=^)|(?<=\s)|(?<=<))" r"(?P(?:href|src)\s*=\s*)" r"(?P['\"])" r"(?P.*?)(?P=quote)" ) diff --git a/tests/test_api.py b/tests/test_api.py index deb090f..84217fa 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -58,6 +58,28 @@ def test_publish_uses_extensionless_links_by_default_for_modern_mode(tmp_path: P assert "|index" in rendered +def test_publish_uses_html_suffix_links_by_default_for_compatibility_mode(tmp_path: Path) -> None: + src = tmp_path / "src" + out = tmp_path / "public" + (src / "content").mkdir(parents=True) + (src / "static").mkdir(parents=True) + (src / "templates").mkdir(parents=True) + + (src / "content" / "about.md").write_text("---\ntitle: About\n---\n\nabout", encoding="utf-8") + (src / "content" / "index.md").write_text("---\ntemplate: default\n---\n\nhome", encoding="utf-8") + (src / "templates" / "default.html.j2").write_text( + "about|{{ page.relurl }}", + encoding="utf-8", + ) + (src / "templates" / "base_default.html.j2").write_text("{{ content }}", encoding="utf-8") + + publish(src, out, "http://localhost/", compatibility_mode=True) + + rendered = (out / "index.html").read_text(encoding="utf-8") + assert "href='about.html'" in rendered + assert "|index.html" in rendered + + def test_publish_can_add_html_suffix_to_links(tmp_path: Path) -> None: src = tmp_path / "src" out = tmp_path / "public" @@ -114,7 +136,7 @@ def test_publish_link_rewrite_skips_data_attrs_and_script_text(tmp_path: Path) - (src / "content" / "index.md").write_text("---\ntemplate: default\n---\n\nhome", encoding="utf-8") (src / "templates" / "default.html.j2").write_text( ( - "about" + "about" "
    data
    " "" "" @@ -127,6 +149,7 @@ def test_publish_link_rewrite_skips_data_attrs_and_script_text(tmp_path: Path) - rendered = (out / "index.html").read_text(encoding="utf-8") assert "href='about.html'" in rendered + assert "title='a>b'" in rendered assert "src='about.html'" in rendered assert "data-href='about'" in rendered assert "href='about'\";" in rendered From 05a900152087faa96786ccf30eb2f2ee4c5139ab Mon Sep 17 00:00:00 2001 From: Rickard Armiento Date: Sat, 28 Mar 2026 11:55:21 +0000 Subject: [PATCH 08/10] Use attribute-level tag parsing for safe publish link rewriting --- src/httk/web/engine/site_engine.py | 92 +++++++++++++++++++++++++----- tests/test_api.py | 2 + 2 files changed, 81 insertions(+), 13 deletions(-) diff --git a/src/httk/web/engine/site_engine.py b/src/httk/web/engine/site_engine.py index 2a62496..c25639a 100644 --- a/src/httk/web/engine/site_engine.py +++ b/src/httk/web/engine/site_engine.py @@ -394,9 +394,6 @@ def _route_url_path(self, route_key: str, *, render_mode: str) -> str: return route_key _HTML_TAG_PATTERN = re.compile(r"""<(?:[^<>"']+|"[^"]*"|'[^']*')+>""") - _URL_ATTR_PATTERN = re.compile( - r"(?:(?<=^)|(?<=\s)|(?<=<))" r"(?P(?:href|src)\s*=\s*)" r"(?P['\"])" r"(?P.*?)(?P=quote)" - ) _ASSET_EXTENSIONS = { ".css", ".js", @@ -427,24 +424,93 @@ def _rewrite_publish_links(self, html: str, *, route_key: str) -> str: route_exists_cache: dict[str, bool] = {} - def replace_attr(match: re.Match[str]) -> str: - original_url = match.group("url") - rewritten_url = self._rewrite_internal_url( - original_url, route_key=route_key, route_exists_cache=route_exists_cache - ) - if rewritten_url is None: - return match.group(0) - return f"{match.group('prefix')}{match.group('quote')}{rewritten_url}{match.group('quote')}" - def replace_tag(match: re.Match[str]) -> str: tag = match.group(0) # Keep declarations/comments/closing tags untouched. if tag.startswith(" str: + n = len(tag) + if n < 3 or not tag.startswith("<") or not tag.endswith(">"): + return tag + + i = 1 + while i < n - 1 and tag[i].isspace(): + i += 1 + + # Skip tag name. + while i < n - 1 and not tag[i].isspace() and tag[i] not in {"/", ">"}: + i += 1 + + parts: list[str] = [] + last = 0 + while i < n - 1: + while i < n - 1 and tag[i].isspace(): + i += 1 + if i >= n - 1 or tag[i] in {">", "/"}: + break + + name_start = i + while i < n - 1 and not tag[i].isspace() and tag[i] not in {"=", ">", "/"}: + i += 1 + if i == name_start: + i += 1 + continue + attr_name = tag[name_start:i].lower() + + while i < n - 1 and tag[i].isspace(): + i += 1 + if i >= n - 1 or tag[i] != "=": + continue + i += 1 + + while i < n - 1 and tag[i].isspace(): + i += 1 + if i >= n - 1: + break + + if tag[i] in {"'", '"'}: + quote = tag[i] + value_start = i + 1 + value_end = tag.find(quote, value_start) + if value_end < 0: + break + raw_value = tag[value_start:value_end] + if attr_name in {"href", "src"}: + rewritten_value = self._rewrite_internal_url( + raw_value, route_key=route_key, route_exists_cache=route_exists_cache + ) + if rewritten_value is not None: + parts.append(tag[last:value_start]) + parts.append(rewritten_value) + last = value_end + i = value_end + 1 + continue + + # Unquoted attribute value. + value_start = i + while i < n - 1 and not tag[i].isspace() and tag[i] != ">": + i += 1 + value_end = i + raw_value = tag[value_start:value_end] + if attr_name in {"href", "src"}: + rewritten_value = self._rewrite_internal_url( + raw_value, route_key=route_key, route_exists_cache=route_exists_cache + ) + if rewritten_value is not None: + parts.append(tag[last:value_start]) + parts.append(rewritten_value) + last = value_end + + if not parts: + return tag + parts.append(tag[last:]) + return "".join(parts) + def _rewrite_internal_url( self, url: str, diff --git a/tests/test_api.py b/tests/test_api.py index 84217fa..da1a6fe 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -137,6 +137,7 @@ def test_publish_link_rewrite_skips_data_attrs_and_script_text(tmp_path: Path) - (src / "templates" / "default.html.j2").write_text( ( "about" + "" "
    data
    " "" "" @@ -152,6 +153,7 @@ def test_publish_link_rewrite_skips_data_attrs_and_script_text(tmp_path: Path) - assert "title='a>b'" in rendered assert "src='about.html'" in rendered assert "data-href='about'" in rendered + assert "onclick=\"if (x > 0) { console.log('href=about'); }\"" in rendered assert "href='about'\";" in rendered From b417d4ecea7c9f5998b0da60f2eedb128ee6ec65 Mon Sep 17 00:00:00 2001 From: Rickard Armiento Date: Sat, 28 Mar 2026 12:09:20 +0000 Subject: [PATCH 09/10] Normalize source-suffix links during publish rewrite to prevent broken .md/.rst URLs --- src/httk/web/engine/site_engine.py | 7 +++++-- tests/test_api.py | 11 ++++++++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/httk/web/engine/site_engine.py b/src/httk/web/engine/site_engine.py index c25639a..2ffe2d9 100644 --- a/src/httk/web/engine/site_engine.py +++ b/src/httk/web/engine/site_engine.py @@ -567,8 +567,11 @@ def _candidate_route_from_link_path(self, path: str, *, route_key: str) -> str | if joined.startswith("../"): return None - if joined.endswith(".html"): - joined = joined[: -len(".html")] + joined_lower = joined.lower() + for suffix in sorted(RENDERERS_BY_SUFFIX.keys(), key=len, reverse=True): + if joined_lower.endswith(suffix): + joined = joined[: -len(suffix)] + break return normalize_route(joined) diff --git a/tests/test_api.py b/tests/test_api.py index da1a6fe..c255f00 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -110,8 +110,16 @@ def test_publish_rewrites_markdown_internal_links_with_html_suffix(tmp_path: Pat (src / "templates").mkdir(parents=True) (src / "content" / "about.md").write_text("---\ntitle: About\n---\n\nabout", encoding="utf-8") + (src / "content" / "guide.rst").write_text("Guide\n=====\n\nGuide body.\n", encoding="utf-8") (src / "content" / "index.md").write_text( - "---\ntemplate: default\n---\n\n[About](about)\n[Query](about?x=1#top)\n[External](https://example.com)\n", + ( + "---\ntemplate: default\n---\n\n" + "[About](about)\n" + "[AboutMd](about.md)\n" + "[GuideRst](guide.rst)\n" + "[Query](about?x=1#top)\n" + "[External](https://example.com)\n" + ), encoding="utf-8", ) (src / "templates" / "default.html.j2").write_text("{{ content }}", encoding="utf-8") @@ -121,6 +129,7 @@ def test_publish_rewrites_markdown_internal_links_with_html_suffix(tmp_path: Pat rendered = (out / "index.html").read_text(encoding="utf-8") assert 'href="about.html"' in rendered + assert 'href="guide.html"' in rendered assert 'href="about.html?x=1#top"' in rendered assert 'href="https://example.com"' in rendered From b83977196989beaf0359d4ccd92ff60fe58a8566 Mon Sep 17 00:00:00 2001 From: Rickard Armiento Date: Sat, 28 Mar 2026 13:36:48 +0100 Subject: [PATCH 10/10] Update docs/how_it_works.md to be more precise on slashes in URLs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/how_it_works.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/how_it_works.md b/docs/how_it_works.md index 8991186..09c0f66 100644 --- a/docs/how_it_works.md +++ b/docs/how_it_works.md @@ -48,8 +48,8 @@ publish("src", "public", "http://127.0.0.1/") To control link style in published output: ```python -publish("src", "public", "http://127.0.0.1/", use_urls_without_ext=False) # -> /about.html -publish("src", "public", "http://127.0.0.1/", use_urls_without_ext=True) # -> /about +publish("src", "public", "http://127.0.0.1/", use_urls_without_ext=False) # -> about.html +publish("src", "public", "http://127.0.0.1/", use_urls_without_ext=True) # -> about ``` ### Examples