diff --git a/CLAUDE.md b/CLAUDE.md
index b8b1cb2..0d0c2a1 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -89,17 +89,21 @@ SELECT * FROM mps_crypto_payment WHERE id = 'paymentuuidherewithoutdashes';
When making changes to database models, always create Alembic migrations:
### Creating Migrations
+
+**CRITICAL**: ALWAYS use `alembic revision` to generate migration files. NEVER manually create migration files or make up revision IDs. Alembic generates unique revision IDs that are required for proper migration tracking.
+
```bash
# Activate environment first
source env/bin/activate
-# Create a new migration (manual)
+# Create a new migration (manual) - ALWAYS use this command
alembic -c data/development.ini revision -m "description of change"
# OR: Create autogenerated migration (compares DB with models)
alembic -c data/development.ini revision --autogenerate -m "description of change"
# Edit the generated migration file in make_post_sell/scripts/alembic/versions/
+# The file will have a proper unique ID like: 05be3044c2d2_description_of_change.py
```
### Running Migrations
diff --git a/make_post_sell/models/shop.py b/make_post_sell/models/shop.py
index e7f0bc1..3851b10 100644
--- a/make_post_sell/models/shop.py
+++ b/make_post_sell/models/shop.py
@@ -67,6 +67,9 @@ class Shop(RBase, Base):
google_analytics_id = Column(Unicode(32), nullable=True, default="")
plausible_domain_name = Column(Unicode(256), nullable=True, default="")
+ # Google Search Console site verification code
+ google_site_verification = Column(Unicode(128), nullable=True, default="")
+
# example: "cus_12345678AbCdEF" but may be null.
# this is how the shop pays for make_post_sell.
stripe_id = Column(Unicode(32), unique=True, nullable=True)
diff --git a/make_post_sell/routes.py b/make_post_sell/routes.py
index 4fdb3cc..3c010d0 100644
--- a/make_post_sell/routes.py
+++ b/make_post_sell/routes.py
@@ -2,6 +2,15 @@ def includeme(config):
config.add_static_view("static", "static", cache_max_age=3600)
config.add_route("favicon", "/favicon.ico")
config.add_route("robots", "/robots.txt")
+
+ # Sitemap and feed routes
+ config.add_route("sitemap", "/sitemap.xml")
+ config.add_route("rss", "/rss.xml")
+ config.add_route("atom", "/atom.xml")
+ config.add_route("feed_xml", "/feed.xml")
+ config.add_route("feed_rss", "/feed.rss")
+ config.add_route("feed_atom", "/feed.atom")
+
config.add_route("markup-editor-preview", "/markup-editor-preview")
config.add_route("ask-for-on-demand-tls", "/ask-for-on-demand-tls")
diff --git a/make_post_sell/scripts/alembic/versions/05be3044c2d2_add_google_site_verification_to_shop.py b/make_post_sell/scripts/alembic/versions/05be3044c2d2_add_google_site_verification_to_shop.py
new file mode 100644
index 0000000..3093b13
--- /dev/null
+++ b/make_post_sell/scripts/alembic/versions/05be3044c2d2_add_google_site_verification_to_shop.py
@@ -0,0 +1,32 @@
+"""add google_site_verification to shop
+
+Revision ID: 05be3044c2d2
+Revises: 3734955e7379
+Create Date: 2026-01-11 08:50:51.206970
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '05be3044c2d2'
+down_revision = '3734955e7379'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ op.add_column(
+ "mps_shop",
+ sa.Column(
+ "google_site_verification",
+ sa.Unicode(length=128),
+ nullable=True,
+ server_default="",
+ ),
+ )
+
+
+def downgrade():
+ op.drop_column("mps_shop", "google_site_verification")
diff --git a/make_post_sell/templates/base.j2 b/make_post_sell/templates/base.j2
index 3116dd1..f051a3f 100644
--- a/make_post_sell/templates/base.j2
+++ b/make_post_sell/templates/base.j2
@@ -67,6 +67,10 @@
{% endif %}
+ {% if request.path == '/' and request.shop and request.shop.google_site_verification %}
+
+ {% endif %}
+
{%- block append_to_head_tag_section %}
{{ request.domain }}
{% endblock %}
diff --git a/make_post_sell/templates/shop_settings.j2 b/make_post_sell/templates/shop_settings.j2
index 1508e1a..903e213 100644
--- a/make_post_sell/templates/shop_settings.j2
+++ b/make_post_sell/templates/shop_settings.j2
@@ -106,6 +106,21 @@
+
+
+
+ Paste the verification code from Google Search Console (just the code, not the full meta tag)
+
+
+
+
diff --git a/make_post_sell/views/feeds.py b/make_post_sell/views/feeds.py
new file mode 100644
index 0000000..0c310f5
--- /dev/null
+++ b/make_post_sell/views/feeds.py
@@ -0,0 +1,250 @@
+"""
+Sitemap and feed views for shops.
+
+Provides XML sitemap and RSS/Atom feeds for public products and content.
+Only includes public items (visibility=1), excludes unlisted, private,
+coupons, carts, and user profile pages.
+"""
+from datetime import datetime
+from xml.etree.ElementTree import Element, SubElement, tostring
+
+from pyramid.response import Response
+from pyramid.view import view_config
+
+from ..models.product import Product
+
+
+def timestamp_to_iso8601(timestamp):
+ """Convert millisecond timestamp to ISO 8601 format."""
+ if timestamp:
+ dt = datetime.fromtimestamp(timestamp / 1000.0)
+ return dt.strftime("%Y-%m-%dT%H:%M:%S+00:00")
+ return datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S+00:00")
+
+
+def timestamp_to_rfc822(timestamp):
+ """Convert millisecond timestamp to RFC 822 format for RSS."""
+ if timestamp:
+ dt = datetime.fromtimestamp(timestamp / 1000.0)
+ return dt.strftime("%a, %d %b %Y %H:%M:%S +0000")
+ return datetime.utcnow().strftime("%a, %d %b %Y %H:%M:%S +0000")
+
+
+def get_public_items(shop):
+ """Get all public products and content from a shop."""
+ if not shop:
+ return [], []
+
+ products = (
+ shop.products.filter(Product.visibility == 1, Product.is_sellable == True)
+ .order_by(Product.updated_timestamp.desc())
+ .all()
+ )
+
+ content = (
+ shop.products.filter(Product.visibility == 1, Product.is_sellable == False)
+ .order_by(Product.updated_timestamp.desc())
+ .all()
+ )
+
+ return products, content
+
+
+def build_sitemap_xml(request, shop, products, content):
+ """Build XML sitemap for the shop."""
+ urlset = Element("urlset")
+ urlset.set("xmlns", "http://www.sitemaps.org/schemas/sitemap/0.9")
+
+ # Shop home page
+ url = SubElement(urlset, "url")
+ SubElement(url, "loc").text = shop.absolute_url(request)
+ SubElement(url, "lastmod").text = timestamp_to_iso8601(shop.updated_timestamp)
+ SubElement(url, "changefreq").text = "weekly"
+ SubElement(url, "priority").text = "1.0"
+
+ # Shop about page
+ url = SubElement(urlset, "url")
+ SubElement(url, "loc").text = shop.absolute_about_url(request)
+ SubElement(url, "lastmod").text = timestamp_to_iso8601(shop.updated_timestamp)
+ SubElement(url, "changefreq").text = "monthly"
+ SubElement(url, "priority").text = "0.5"
+
+ # Terms of service (if exists)
+ if shop.terms_of_service_raw:
+ url = SubElement(urlset, "url")
+ SubElement(url, "loc").text = shop.absolute_terms_url(request)
+ SubElement(url, "lastmod").text = timestamp_to_iso8601(shop.updated_timestamp)
+ SubElement(url, "changefreq").text = "yearly"
+ SubElement(url, "priority").text = "0.3"
+
+ # Privacy policy (if exists)
+ if shop.privacy_policy_raw:
+ url = SubElement(urlset, "url")
+ SubElement(url, "loc").text = shop.absolute_privacy_policy_url(request)
+ SubElement(url, "lastmod").text = timestamp_to_iso8601(shop.updated_timestamp)
+ SubElement(url, "changefreq").text = "yearly"
+ SubElement(url, "priority").text = "0.3"
+
+ # Public products
+ for product in products:
+ url = SubElement(urlset, "url")
+ SubElement(url, "loc").text = product.absolute_url(request)
+ SubElement(url, "lastmod").text = timestamp_to_iso8601(product.updated_timestamp)
+ SubElement(url, "changefreq").text = "weekly"
+ SubElement(url, "priority").text = "0.8"
+
+ # Public content
+ for item in content:
+ url = SubElement(urlset, "url")
+ SubElement(url, "loc").text = item.absolute_url(request)
+ SubElement(url, "lastmod").text = timestamp_to_iso8601(item.updated_timestamp)
+ SubElement(url, "changefreq").text = "weekly"
+ SubElement(url, "priority").text = "0.7"
+
+ return tostring(urlset, encoding="unicode", method="xml")
+
+
+def build_rss_xml(request, shop, products, content):
+ """Build RSS 2.0 feed for the shop."""
+ rss = Element("rss")
+ rss.set("version", "2.0")
+ rss.set("xmlns:atom", "http://www.w3.org/2005/Atom")
+
+ channel = SubElement(rss, "channel")
+ SubElement(channel, "title").text = shop.name
+ SubElement(channel, "link").text = shop.absolute_url(request)
+ SubElement(channel, "description").text = shop.description or f"Products and content from {shop.name}"
+ SubElement(channel, "language").text = "en-us"
+ SubElement(channel, "lastBuildDate").text = timestamp_to_rfc822(shop.updated_timestamp)
+
+ # Atom self-link for feed validation
+ atom_link = SubElement(channel, "atom:link")
+ atom_link.set("href", f"{request.host_url}/rss.xml")
+ atom_link.set("rel", "self")
+ atom_link.set("type", "application/rss+xml")
+
+ # Combine and sort by updated timestamp
+ all_items = [(p, p.updated_timestamp) for p in products] + [
+ (c, c.updated_timestamp) for c in content
+ ]
+ all_items.sort(key=lambda x: x[1], reverse=True)
+
+ for item_obj, _ in all_items:
+ item = SubElement(channel, "item")
+ SubElement(item, "title").text = item_obj.title
+ SubElement(item, "link").text = item_obj.absolute_url(request)
+ SubElement(item, "guid").text = item_obj.absolute_url(request)
+ SubElement(item, "pubDate").text = timestamp_to_rfc822(item_obj.created_timestamp)
+
+ # Use description_html stripped of tags, or fall back to raw
+ if item_obj.description:
+ SubElement(item, "description").text = item_obj.description[:500]
+
+ return tostring(rss, encoding="unicode", method="xml")
+
+
+def build_atom_xml(request, shop, products, content):
+ """Build Atom feed for the shop."""
+ feed = Element("feed")
+ feed.set("xmlns", "http://www.w3.org/2005/Atom")
+
+ SubElement(feed, "title").text = shop.name
+ SubElement(feed, "id").text = shop.absolute_url(request)
+ SubElement(feed, "updated").text = timestamp_to_iso8601(shop.updated_timestamp)
+
+ # Self link
+ self_link = SubElement(feed, "link")
+ self_link.set("href", f"{request.host_url}/atom.xml")
+ self_link.set("rel", "self")
+ self_link.set("type", "application/atom+xml")
+
+ # Alternate link
+ alt_link = SubElement(feed, "link")
+ alt_link.set("href", shop.absolute_url(request))
+ alt_link.set("rel", "alternate")
+ alt_link.set("type", "text/html")
+
+ if shop.description:
+ SubElement(feed, "subtitle").text = shop.description
+
+ # Combine and sort by updated timestamp
+ all_items = [(p, p.updated_timestamp) for p in products] + [
+ (c, c.updated_timestamp) for c in content
+ ]
+ all_items.sort(key=lambda x: x[1], reverse=True)
+
+ for item_obj, _ in all_items:
+ entry = SubElement(feed, "entry")
+ SubElement(entry, "title").text = item_obj.title
+ SubElement(entry, "id").text = item_obj.absolute_url(request)
+ SubElement(entry, "updated").text = timestamp_to_iso8601(item_obj.updated_timestamp)
+ SubElement(entry, "published").text = timestamp_to_iso8601(item_obj.created_timestamp)
+
+ link = SubElement(entry, "link")
+ link.set("href", item_obj.absolute_url(request))
+ link.set("rel", "alternate")
+ link.set("type", "text/html")
+
+ if item_obj.description:
+ summary = SubElement(entry, "summary")
+ summary.set("type", "text")
+ summary.text = item_obj.description[:500]
+
+ return tostring(feed, encoding="unicode", method="xml")
+
+
+@view_config(route_name="sitemap")
+def sitemap_view(request):
+ """Generate XML sitemap for the shop."""
+ shop = request.shop
+
+ if not shop:
+ response = Response(body="")
+ response.content_type = "application/xml"
+ return response
+
+ products, content = get_public_items(shop)
+ xml = build_sitemap_xml(request, shop, products, content)
+
+ response = Response(body=f"\n{xml}")
+ response.content_type = "application/xml"
+ return response
+
+
+@view_config(route_name="rss")
+@view_config(route_name="feed_xml")
+@view_config(route_name="feed_rss")
+def rss_view(request):
+ """Generate RSS 2.0 feed for the shop."""
+ shop = request.shop
+
+ if not shop:
+ response = Response(body="")
+ response.content_type = "application/rss+xml"
+ return response
+
+ products, content = get_public_items(shop)
+ xml = build_rss_xml(request, shop, products, content)
+
+ response = Response(body=f"\n{xml}")
+ response.content_type = "application/rss+xml"
+ return response
+
+
+@view_config(route_name="atom")
+@view_config(route_name="feed_atom")
+def atom_view(request):
+ """Generate Atom feed for the shop."""
+ shop = request.shop
+
+ if not shop:
+ response = Response(body="")
+ response.content_type = "application/atom+xml"
+ return response
+
+ products, content = get_public_items(shop)
+ xml = build_atom_xml(request, shop, products, content)
+
+ response = Response(body=f"\n{xml}")
+ response.content_type = "application/atom+xml"
+ return response
diff --git a/make_post_sell/views/misc.py b/make_post_sell/views/misc.py
index 31fa0ab..49dab06 100644
--- a/make_post_sell/views/misc.py
+++ b/make_post_sell/views/misc.py
@@ -29,10 +29,17 @@ def favicon_view(request):
@view_config(route_name="robots")
def robots_view(request):
- """Load and return either default robots.txt or version from .ini"""
- response = Response(
- body=request.app.get("robots_dot_txt", DEFAULT_ROBOTS_DOT_TXT).lstrip()
- )
+ """Load and return robots.txt with sitemap reference for shops."""
+ base_robots = request.app.get("robots_dot_txt", DEFAULT_ROBOTS_DOT_TXT).lstrip()
+
+ # Add sitemap reference for shops
+ if request.shop:
+ sitemap_url = f"{request.host_url}/sitemap.xml"
+ body = f"{base_robots}\nSitemap: {sitemap_url}\n"
+ else:
+ body = base_robots
+
+ response = Response(body=body)
response.content_type = "text/plain"
return response
diff --git a/make_post_sell/views/shop.py b/make_post_sell/views/shop.py
index 3fb9a1c..11a67cb 100644
--- a/make_post_sell/views/shop.py
+++ b/make_post_sell/views/shop.py
@@ -395,6 +395,10 @@ def shop_settings(request):
"plausible_domain_name", shop.plausible_domain_name or ""
).strip()
+ google_site_verification = request.params.get(
+ "google_site_verification", shop.google_site_verification or ""
+ ).strip()
+
stripe_test_mode = request.app.get("stripe.test_mode", False)
stripe_public_api_key = request.params.get(
"stripe_public_api_key", shop.stripe_public_api_key
@@ -529,6 +533,14 @@ def shop_settings(request):
msg = ("You set the shop's Plausible Analytics Domain Name.", "success")
request.session.flash(msg)
+ if google_site_verification != (shop.google_site_verification or ""):
+ shop.google_site_verification = google_site_verification
+ if google_site_verification:
+ msg = ("You set the shop's Google Site Verification code.", "success")
+ else:
+ msg = ("You cleared the shop's Google Site Verification code.", "success")
+ request.session.flash(msg)
+
# Handle ribbon settings form
if form_section == "ribbon-settings":
if ribbon_text != (shop.ribbon_text or ""):
@@ -963,6 +975,7 @@ def shop_settings(request):
"default_theme": shop.default_theme,
"google_analytics_id": shop.google_analytics_id or "",
"plausible_domain_name": shop.plausible_domain_name or "",
+ "google_site_verification": shop.google_site_verification or "",
"stripe_public_api_key": shop.stripe_public_api_key or "",
"stripe_secret_api_key": shop.stripe_secret_api_key or "",
"paypal_client_id": shop.paypal_client_id or "",