|
2 | 2 |
|
3 | 3 | import datetime |
4 | 4 | from collections.abc import Sequence |
| 5 | +from dataclasses import dataclass |
5 | 6 | from pathlib import Path |
6 | 7 | from types import SimpleNamespace |
7 | 8 | from typing import TYPE_CHECKING, Literal, TypedDict |
|
16 | 17 | if TYPE_CHECKING: |
17 | 18 | from reflex.app import UnevaluatedPage |
18 | 19 |
|
| 20 | +TrailingSlashOption = Literal["always", "never", "preserve"] |
| 21 | + |
19 | 22 | Location = str |
20 | 23 | LastModified = datetime.datetime |
21 | 24 | ChangeFrequency = Literal[ |
@@ -49,20 +52,30 @@ class Constants(SimpleNamespace): |
49 | 52 |
|
50 | 53 |
|
51 | 54 | def configuration_with_loc( |
52 | | - *, config: SitemapLinkConfiguration, deploy_url: str | None, loc: Location |
| 55 | + *, |
| 56 | + config: SitemapLinkConfiguration, |
| 57 | + deploy_url: str | None, |
| 58 | + loc: Location, |
| 59 | + trailing_slash: TrailingSlashOption, |
53 | 60 | ) -> SitemapLink: |
54 | 61 | """Set the 'loc' field of the configuration. |
55 | 62 |
|
56 | 63 | Args: |
57 | 64 | config: The configuration dictionary. |
58 | 65 | deploy_url: The deployment URL, if any. |
59 | 66 | loc: The location to set. |
| 67 | + trailing_slash: Option for handling trailing slashes in URLs. |
60 | 68 |
|
61 | 69 | Returns: |
62 | 70 | A SitemapLink dictionary with the 'loc' field set. |
63 | 71 | """ |
64 | 72 | if deploy_url and not loc.startswith("http://") and not loc.startswith("https://"): |
65 | 73 | loc = f"{deploy_url.rstrip('/')}/{loc.lstrip('/')}" |
| 74 | + if trailing_slash == "always" and not loc.endswith("/"): |
| 75 | + loc += "/" |
| 76 | + elif trailing_slash == "never": |
| 77 | + stripped = loc.rstrip("/") |
| 78 | + loc = stripped or loc |
66 | 79 | link: SitemapLink = {"loc": loc} |
67 | 80 | if (lastmod := config.get("lastmod")) is not None: |
68 | 81 | link["lastmod"] = lastmod |
@@ -121,11 +134,13 @@ def is_route_dynamic(route: str) -> bool: |
121 | 134 |
|
122 | 135 | def generate_links_for_sitemap( |
123 | 136 | unevaluated_pages: Sequence["UnevaluatedPage"], |
| 137 | + trailing_slash: TrailingSlashOption, |
124 | 138 | ) -> list[SitemapLink]: |
125 | 139 | """Generate sitemap links from unevaluated pages. |
126 | 140 |
|
127 | 141 | Args: |
128 | 142 | unevaluated_pages: Sequence of unevaluated pages. |
| 143 | + trailing_slash: Option for handling trailing slashes in URLs. |
129 | 144 |
|
130 | 145 | Returns: |
131 | 146 | A list of SitemapLink dictionaries. |
@@ -159,52 +174,67 @@ def generate_links_for_sitemap( |
159 | 174 | continue |
160 | 175 |
|
161 | 176 | sitemap_link = configuration_with_loc( |
162 | | - config=sitemap_config, deploy_url=deploy_url, loc=loc |
| 177 | + config=sitemap_config, |
| 178 | + deploy_url=deploy_url, |
| 179 | + loc=loc, |
| 180 | + trailing_slash=trailing_slash, |
163 | 181 | ) |
164 | 182 |
|
165 | 183 | elif (loc := sitemap_config.get("loc")) is not None: |
166 | 184 | sitemap_link = configuration_with_loc( |
167 | | - config=sitemap_config, deploy_url=deploy_url, loc=loc |
| 185 | + config=sitemap_config, |
| 186 | + deploy_url=deploy_url, |
| 187 | + loc=loc, |
| 188 | + trailing_slash=trailing_slash, |
168 | 189 | ) |
169 | 190 |
|
170 | 191 | else: |
171 | 192 | loc = page.route if page.route != "index" else "/" |
172 | 193 | if not loc.startswith("/"): |
173 | 194 | loc = "/" + loc |
174 | 195 | sitemap_link = configuration_with_loc( |
175 | | - config=sitemap_config, deploy_url=deploy_url, loc=loc |
| 196 | + config=sitemap_config, |
| 197 | + deploy_url=deploy_url, |
| 198 | + loc=loc, |
| 199 | + trailing_slash=trailing_slash, |
176 | 200 | ) |
177 | 201 |
|
178 | 202 | links.append(sitemap_link) |
179 | 203 | return links |
180 | 204 |
|
181 | 205 |
|
182 | | -def sitemap_task(unevaluated_pages: Sequence["UnevaluatedPage"]) -> tuple[str, str]: |
| 206 | +def sitemap_task( |
| 207 | + unevaluated_pages: Sequence["UnevaluatedPage"], trailing_slash: TrailingSlashOption |
| 208 | +) -> tuple[str, str]: |
183 | 209 | """Task to generate the sitemap XML file. |
184 | 210 |
|
185 | 211 | Args: |
186 | 212 | unevaluated_pages: Sequence of unevaluated pages. |
| 213 | + trailing_slash: Option for handling trailing slashes in URLs. |
187 | 214 |
|
188 | 215 | Returns: |
189 | 216 | A tuple containing the file path and the generated XML content. |
190 | 217 | """ |
191 | 218 | return ( |
192 | 219 | str(Constants.FILE_PATH), |
193 | | - generate_xml(generate_links_for_sitemap(unevaluated_pages)), |
| 220 | + generate_xml(generate_links_for_sitemap(unevaluated_pages, trailing_slash)), |
194 | 221 | ) |
195 | 222 |
|
196 | 223 |
|
| 224 | +@dataclass(kw_only=True, frozen=True) |
197 | 225 | class SitemapPlugin(PluginBase): |
198 | 226 | """Sitemap plugin for Reflex.""" |
199 | 227 |
|
| 228 | + trailing_slash: TrailingSlashOption = "preserve" |
| 229 | + |
200 | 230 | def pre_compile(self, **context): |
201 | 231 | """Generate the sitemap XML file before compilation. |
202 | 232 |
|
203 | 233 | Args: |
204 | 234 | context: The context for the plugin. |
205 | 235 | """ |
206 | 236 | unevaluated_pages = context.get("unevaluated_pages", []) |
207 | | - context["add_save_task"](sitemap_task, unevaluated_pages) |
| 237 | + context["add_save_task"](sitemap_task, unevaluated_pages, self.trailing_slash) |
208 | 238 |
|
209 | 239 |
|
210 | 240 | Plugin = SitemapPlugin |
0 commit comments