Skip to content

Self-hosted: static SEO files, canonical policy and responsive media - #1463

Merged
feruzm merged 4 commits into
developfrom
feature/self-hosted-seo-media
Aug 12, 2026
Merged

Self-hosted: static SEO files, canonical policy and responsive media#1463
feruzm merged 4 commits into
developfrom
feature/self-hosted-seo-media

Conversation

@feruzm

@feruzm feruzm commented Aug 12, 2026

Copy link
Copy Markdown
Member

Closes #1451
Closes #1452

Implements the approved proposals from both research issues; static-only, no new request-time compute.

Static SEO files (#1451)

Per-tenant robots.txt (with its Sitemap line), sitemap.xml (home, about and the latest posts with lastmod) and rss.xml (RSS 2.0 with atom self-link, escaped chain text, RFC822 dates) are written by the existing sync pass into the served configs volume: one bounded bridge page per tenant, a thirty-minute freshness window (files are touched after unchanged writes so write-if-changed cannot defeat the mtime check), the same per-tenant locks, delete path and reconcile sweep as every other served file. nginx serves them per tenant in both server blocks, generic robots as fallback. Managed instances now advertise their OWN feed — crawler-visible from the SSI head snippet — instead of pointing at ecency.com from client-injected markup crawlers never ran.

Canonical policy (#1451, as decided)

A tenant with a verified custom domain canonicalizes to itself; a subdomain tenant canonicalizes to the ecency.com SSR page, home and per-post alike. Derived from the tenant row, no config option. og:url stays the served URL.

Responsive media (#1452)

Both card layouts and the Journal thumbnail emit srcset/sizes from render-helper's builders, so phones stop downloading desktop cuts (the CSS height token already pins the boxes, so CLS was bounded). The per-post og:image goes through render-helper's own cover extraction and the modern hashed proxy path instead of a local regex duplicate and the legacy redirecting sized route.

render-helper loads through its CJS build in the hosting API: the package's Node ESM entry crashes at module load on a directory import — that packaging fix plus a body-image dimensions option are filed as #1461, and the missing editor image upload as #1462.

Tests

  • 7 new SEO suites (builders, escaping, dates, canonical policy both ways, feed routing per instance kind, malformed responses)
  • Updated post-meta cover assertion to the modern proxy path; TenantService mocks extended where the builders now reach getBlogUrl
  • Full suites green: 930 SPA, 443 hosting API; both typechecks; SPA production build

Summary by CodeRabbit

  • New Features

    • Added tenant-specific robots.txt, sitemap.xml, and RSS feeds.
    • Improved canonical URLs and metadata for custom domains and community sites.
    • Added responsive image loading for blog and Journal post cards.
    • Managed instances now support local or configured RSS feed URLs.
    • Added scheduled SEO file generation for self-hosted deployments.
  • Bug Fixes

    • Improved post excerpts, image previews, HTML/XML escaping, and RSS metadata.
    • Updated image proxy URLs for more reliable social previews.
    • Improved atomic configuration and SEO file updates.

Per-tenant robots.txt (with its Sitemap line), sitemap.xml and rss.xml are
written by the existing sync pass into the served configs volume, built
from one bounded bridge page (latest 100 posts), regenerated on a
thirty-minute freshness window (files touched after unchanged writes so
writeIfChanged cannot defeat the mtime check) and cleaned up by the same
delete path and reconcile sweep as every other served file. nginx serves
them per tenant in both server blocks with the generic robots as
fallback. Managed instances now link their OWN feed (crawler-visible from
the SSI snippet, and getRssFeedUrl follows for the visible link) instead
of pointing at ecency.com.

Canonical policy: a tenant with a verified custom domain canonicalizes to
itself, a subdomain tenant to the ecency.com SSR page (home and per-post
alike); og:url stays the served URL.

Media: both card layouts and the Journal thumbnail emit srcset and sizes
from render-helper's builders, so a phone stops downloading the desktop
cut, and the per-post og:image goes through render-helper's own cover
extraction and modern proxy path instead of a local regex duplicate and
the legacy redirecting sized route. render-helper loads through its CJS
build in the API: the package's node ESM entry crashes on a directory
import, tracked in #1461. escapeHtml and the excerpt move to their own
utils, since config-service, post-meta and seo-files now consume each
other.
@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Aug 12, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (2) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (1)

Grey Divider


Action required

1. Malformed response erases posts ✓ Resolved 🐞 Bug ≡ Correctness
Description
fetchTenantPosts treats a malformed non-array bridge response as a successful empty feed, so
regeneration overwrites valid sitemap/RSS post entries and marks the empty files fresh for 30
minutes. Reject malformed responses instead so the last known-good files remain served.
Code

apps/self-hosted/hosting/api/src/services/seo-files.ts[114]

+  if (!Array.isArray(raw)) return [];
Relevance

●● Moderate

Behavior change conflicts with newly added test; team may prefer current fail-open semantics.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The malformed value becomes [], after which the config service builds files from that empty list
and touches them; the new test explicitly locks in this destructive behavior.

apps/self-hosted/hosting/api/src/services/seo-files.ts[94-119]
apps/self-hosted/hosting/api/src/services/config-service.ts[236-247]
apps/self-hosted/hosting/api/src/services/seo-files.test.ts[138-140]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A non-array bridge response is converted to an empty post list, causing valid sitemap and RSS files to be overwritten and treated as fresh.
## Issue Context
A legitimate empty feed is represented by an empty array; a non-array value is a malformed upstream response and should follow the same failure path as a rejected or timed-out RPC so existing files remain intact.
## Fix Focus Areas
- apps/self-hosted/hosting/api/src/services/seo-files.ts[114-118]
- apps/self-hosted/hosting/api/src/services/config-service.ts[238-247]
- apps/self-hosted/hosting/api/src/services/seo-files.test.ts[138-140]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. render-helper cover proxy ignores tenant image proxy ✓ Resolved 🐞 Bug ≡ Correctness
Description
post-meta.ts now generates the per-post og:image via render-helper's catchPostImage() without ever
calling setProxyBase(), so the module-level proxyBase in @ecency/render-helper stays at its default
'https://i.ecency.com' for every tenant. The removed code previously read
tenant.config.configuration.general.imageProxy per request via proxyBaseOf(tenant); tenants who
configured a custom image proxy now get og:image URLs pointing at the wrong host.
Code

apps/self-hosted/hosting/api/src/services/post-meta.ts[R132-137]

+  // render-helper's own cover extraction and proxying, the same pipeline the
+  // apps render with: it understands string-form metadata, entities and code
+  // fences, and emits the modern proxy path instead of the legacy sized
+  // route that answers with a redirect. Chain-authored, so escaped.
+  const coverProxied = catchPostImage(post, 1200, 630, 'match');
+  const ogImage = coverProxied ? escapeHtml(coverProxied) : null;
Relevance

●● Moderate

Could be a real tenant-specific regression, but depends on whether custom imageProxy is still
supported/required.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
packages/render-helper/src/proxify-image-src.ts defines a module-level `let proxyBase =
'https://i.ecency.com'` that is only mutated by setProxyBase(); the client bundle correctly calls
setProxyBase(imageProxyBase) in apps/self-hosted/src/index.tsx before rendering, but the
hosting-api's post-meta.ts requires the package via createRequire and calls catchPostImage(post,
1200, 630, 'match') directly, with no setProxyBase call anywhere in the file or its imports. The
diff also deletes proxyBaseOf(tenant), which previously read
tenant.config.configuration.general.imageProxy per tenant before building the OG image URL, so
that per-tenant override is now silently dropped for the server-rendered head snippet.

packages/render-helper/src/proxify-image-src.ts[4-63]
apps/self-hosted/src/index.tsx[29-31]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The hosting API's per-post metadata builder generates `og:image`/`twitter:image` via `@ecency/render-helper`'s `catchPostImage()`, but never configures the package's proxy base. render-helper keeps `proxyBase` as module-level mutable state (default `https://i.ecency.com`), mutated only via `setProxyBase()`. The removed code previously read the tenant's `configuration.general.imageProxy` override per request; this behavior is now lost, and any tenant with a custom image proxy gets og:image URLs pointing at the default proxy instead of their configured one.
## Issue Context
- The tenant's configured proxy lives at `tenant.config.configuration.general.imageProxy` (see `apps/self-hosted/hosting/api/src/types.ts` and `tenant-service.ts` defaults).
- `@ecency/render-helper`'s `setProxyBase`/`getProxyBase` mutate module-level state shared by the whole process — this is unsafe for concurrent multi-tenant requests handled by the same Node process, since two tenants' requests could race and get each other's proxy base.
- A safer fix likely requires render-helper to accept an explicit proxy base per call (e.g., an options param) rather than relying on process-global state, or the hosting API must serialize/guard proxy base mutation around each catchPostImage call per tenant.
## Fix Focus Areas
- apps/self-hosted/hosting/api/src/services/post-meta.ts[1-21]
- apps/self-hosted/hosting/api/src/services/post-meta.ts[130-137]
- packages/render-helper/src/proxify-image-src.ts[57-63]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Malformed response erases posts ✓ Resolved 🐞 Bug ≡ Correctness
Description
fetchTenantPosts treats a malformed non-array bridge response as a successful empty feed, so
regeneration overwrites valid sitemap/RSS post entries and marks the empty files fresh for 30
minutes. Reject malformed responses instead so the last known-good files remain served.
Code

apps/self-hosted/hosting/api/src/services/seo-files.ts[114]

+  if (!Array.isArray(raw)) return [];
Relevance

●● Moderate

Behavior change conflicts with newly added test; team may prefer current fail-open semantics.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The malformed value becomes [], after which the config service builds files from that empty list
and touches them; the new test explicitly locks in this destructive behavior.

apps/self-hosted/hosting/api/src/services/seo-files.ts[94-119]
apps/self-hosted/hosting/api/src/services/config-service.ts[236-247]
apps/self-hosted/hosting/api/src/services/seo-files.test.ts[138-140]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A non-array bridge response is converted to an empty post list, causing valid sitemap and RSS files to be overwritten and treated as fresh.
## Issue Context
A legitimate empty feed is represented by an empty array; a non-array value is a malformed upstream response and should follow the same failure path as a rejected or timed-out RPC so existing files remain intact.
## Fix Focus Areas
- apps/self-hosted/hosting/api/src/services/seo-files.ts[114-118]
- apps/self-hosted/hosting/api/src/services/config-service.ts[238-247]
- apps/self-hosted/hosting/api/src/services/seo-files.test.ts[138-140]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View high (5)
4. render-helper cover proxy ignores tenant image proxy ✓ Resolved 🐞 Bug ≡ Correctness
Description
post-meta.ts now generates the per-post og:image via render-helper's catchPostImage() without ever
calling setProxyBase(), so the module-level proxyBase in @ecency/render-helper stays at its default
'https://i.ecency.com' for every tenant. The removed code previously read
tenant.config.configuration.general.imageProxy per request via proxyBaseOf(tenant); tenants who
configured a custom image proxy now get og:image URLs pointing at the wrong host.
Code

apps/self-hosted/hosting/api/src/services/post-meta.ts[R132-137]

+  // render-helper's own cover extraction and proxying, the same pipeline the
+  // apps render with: it understands string-form metadata, entities and code
+  // fences, and emits the modern proxy path instead of the legacy sized
+  // route that answers with a redirect. Chain-authored, so escaped.
+  const coverProxied = catchPostImage(post, 1200, 630, 'match');
+  const ogImage = coverProxied ? escapeHtml(coverProxied) : null;
Relevance

●● Moderate

Could be a real tenant-specific regression, but depends on whether custom imageProxy is still
supported/required.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
packages/render-helper/src/proxify-image-src.ts defines a module-level `let proxyBase =
'https://i.ecency.com'` that is only mutated by setProxyBase(); the client bundle correctly calls
setProxyBase(imageProxyBase) in apps/self-hosted/src/index.tsx before rendering, but the
hosting-api's post-meta.ts requires the package via createRequire and calls catchPostImage(post,
1200, 630, 'match') directly, with no setProxyBase call anywhere in the file or its imports. The
diff also deletes proxyBaseOf(tenant), which previously read
tenant.config.configuration.general.imageProxy per tenant before building the OG image URL, so
that per-tenant override is now silently dropped for the server-rendered head snippet.

packages/render-helper/src/proxify-image-src.ts[4-63]
apps/self-hosted/src/index.tsx[29-31]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The hosting API's per-post metadata builder generates `og:image`/`twitter:image` via `@ecency/render-helper`'s `catchPostImage()`, but never configures the package's proxy base. render-helper keeps `proxyBase` as module-level mutable state (default `https://i.ecency.com`), mutated only via `setProxyBase()`. The removed code previously read the tenant's `configuration.general.imageProxy` override per request; this behavior is now lost, and any tenant with a custom image proxy gets og:image URLs pointing at the default proxy instead of their configured one.
## Issue Context
- The tenant's configured proxy lives at `tenant.config.configuration.general.imageProxy` (see `apps/self-hosted/hosting/api/src/types.ts` and `tenant-service.ts` defaults).
- `@ecency/render-helper`'s `setProxyBase`/`getProxyBase` mutate module-level state shared by the whole process — this is unsafe for concurrent multi-tenant requests handled by the same Node process, since two tenants' requests could race and get each other's proxy base.
- A safer fix likely requires render-helper to accept an explicit proxy base per call (e.g., an options param) rather than relying on process-global state, or the hosting API must serialize/guard proxy base mutation around each catchPostImage call per tenant.
## Fix Focus Areas
- apps/self-hosted/hosting/api/src/services/post-meta.ts[1-21]
- apps/self-hosted/hosting/api/src/services/post-meta.ts[130-137]
- packages/render-helper/src/proxify-image-src.ts[57-63]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. Malformed response erases posts ✓ Resolved 🐞 Bug ≡ Correctness
Description
fetchTenantPosts treats a malformed non-array bridge response as a successful empty feed, so
regeneration overwrites valid sitemap/RSS post entries and marks the empty files fresh for 30
minutes. Reject malformed responses instead so the last known-good files remain served.
Code

apps/self-hosted/hosting/api/src/services/seo-files.ts[114]

+  if (!Array.isArray(raw)) return [];
Relevance

●● Moderate

Behavior change conflicts with newly added test; team may prefer current fail-open semantics.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The malformed value becomes [], after which the config service builds files from that empty list
and touches them; the new test explicitly locks in this destructive behavior.

apps/self-hosted/hosting/api/src/services/seo-files.ts[94-119]
apps/self-hosted/hosting/api/src/services/config-service.ts[236-247]
apps/self-hosted/hosting/api/src/services/seo-files.test.ts[138-140]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A non-array bridge response is converted to an empty post list, causing valid sitemap and RSS files to be overwritten and treated as fresh.
## Issue Context
A legitimate empty feed is represented by an empty array; a non-array value is a malformed upstream response and should follow the same failure path as a rejected or timed-out RPC so existing files remain intact.
## Fix Focus Areas
- apps/self-hosted/hosting/api/src/services/seo-files.ts[114-118]
- apps/self-hosted/hosting/api/src/services/config-service.ts[238-247]
- apps/self-hosted/hosting/api/src/services/seo-files.test.ts[138-140]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. render-helper cover proxy ignores tenant image proxy ✓ Resolved 🐞 Bug ≡ Correctness
Description
post-meta.ts now generates the per-post og:image via render-helper's catchPostImage() without ever
calling setProxyBase(), so the module-level proxyBase in @ecency/render-helper stays at its default
'https://i.ecency.com' for every tenant. The removed code previously read
tenant.config.configuration.general.imageProxy per request via proxyBaseOf(tenant); tenants who
configured a custom image proxy now get og:image URLs pointing at the wrong host.
Code

apps/self-hosted/hosting/api/src/services/post-meta.ts[R132-137]

+  // render-helper's own cover extraction and proxying, the same pipeline the
+  // apps render with: it understands string-form metadata, entities and code
+  // fences, and emits the modern proxy path instead of the legacy sized
+  // route that answers with a redirect. Chain-authored, so escaped.
+  const coverProxied = catchPostImage(post, 1200, 630, 'match');
+  const ogImage = coverProxied ? escapeHtml(coverProxied) : null;
Relevance

●● Moderate

Could be a real tenant-specific regression, but depends on whether custom imageProxy is still
supported/required.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
packages/render-helper/src/proxify-image-src.ts defines a module-level `let proxyBase =
'https://i.ecency.com'` that is only mutated by setProxyBase(); the client bundle correctly calls
setProxyBase(imageProxyBase) in apps/self-hosted/src/index.tsx before rendering, but the
hosting-api's post-meta.ts requires the package via createRequire and calls catchPostImage(post,
1200, 630, 'match') directly, with no setProxyBase call anywhere in the file or its imports. The
diff also deletes proxyBaseOf(tenant), which previously read
tenant.config.configuration.general.imageProxy per tenant before building the OG image URL, so
that per-tenant override is now silently dropped for the server-rendered head snippet.

packages/render-helper/src/proxify-image-src.ts[4-63]
apps/self-hosted/src/index.tsx[29-31]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The hosting API's per-post metadata builder generates `og:image`/`twitter:image` via `@ecency/render-helper`'s `catchPostImage()`, but never configures the package's proxy base. render-helper keeps `proxyBase` as module-level mutable state (default `https://i.ecency.com`), mutated only via `setProxyBase()`. The removed code previously read the tenant's `configuration.general.imageProxy` override per request; this behavior is now lost, and any tenant with a custom image proxy gets og:image URLs pointing at the default proxy instead of their configured one.
## Issue Context
- The tenant's configured proxy lives at `tenant.config.configuration.general.imageProxy` (see `apps/self-hosted/hosting/api/src/types.ts` and `tenant-service.ts` defaults).
- `@ecency/render-helper`'s `setProxyBase`/`getProxyBase` mutate module-level state shared by the whole process — this is unsafe for concurrent multi-tenant requests handled by the same Node process, since two tenants' requests could race and get each other's proxy base.
- A safer fix likely requires render-helper to accept an explicit proxy base per call (e.g., an options param) rather than relying on process-global state, or the hosting API must serialize/guard proxy base mutation around each catchPostImage call per tenant.
## Fix Focus Areas
- apps/self-hosted/hosting/api/src/services/post-meta.ts[1-21]
- apps/self-hosted/hosting/api/src/services/post-meta.ts[130-137]
- packages/render-helper/src/proxify-image-src.ts[57-63]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


7. Malformed response erases posts ✓ Resolved 🐞 Bug ≡ Correctness
Description
fetchTenantPosts treats a malformed non-array bridge response as a successful empty feed, so
regeneration overwrites valid sitemap/RSS post entries and marks the empty files fresh for 30
minutes. Reject malformed responses instead so the last known-good files remain served.
Code

apps/self-hosted/hosting/api/src/services/seo-files.ts[114]

+  if (!Array.isArray(raw)) return [];
Relevance

●● Moderate

Behavior change conflicts with newly added test; team may prefer current fail-open semantics.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The malformed value becomes [], after which the config service builds files from that empty list
and touches them; the new test explicitly locks in this destructive behavior.

apps/self-hosted/hosting/api/src/services/seo-files.ts[94-119]
apps/self-hosted/hosting/api/src/services/config-service.ts[236-247]
apps/self-hosted/hosting/api/src/services/seo-files.test.ts[138-140]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A non-array bridge response is converted to an empty post list, causing valid sitemap and RSS files to be overwritten and treated as fresh.
## Issue Context
A legitimate empty feed is represented by an empty array; a non-array value is a malformed upstream response and should follow the same failure path as a rejected or timed-out RPC so existing files remain intact.
## Fix Focus Areas
- apps/self-hosted/hosting/api/src/services/seo-files.ts[114-118]
- apps/self-hosted/hosting/api/src/services/config-service.ts[238-247]
- apps/self-hosted/hosting/api/src/services/seo-files.test.ts[138-140]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


8. render-helper cover proxy ignores tenant image proxy ✓ Resolved 🐞 Bug ≡ Correctness
Description
post-meta.ts now generates the per-post og:image via render-helper's catchPostImage() without ever
calling setProxyBase(), so the module-level proxyBase in @ecency/render-helper stays at its default
'https://i.ecency.com' for every tenant. The removed code previously read
tenant.config.configuration.general.imageProxy per request via proxyBaseOf(tenant); tenants who
configured a custom image proxy now get og:image URLs pointing at the wrong host.
Code

apps/self-hosted/hosting/api/src/services/post-meta.ts[R132-137]

+  // render-helper's own cover extraction and proxying, the same pipeline the
+  // apps render with: it understands string-form metadata, entities and code
+  // fences, and emits the modern proxy path instead of the legacy sized
+  // route that answers with a redirect. Chain-authored, so escaped.
+  const coverProxied = catchPostImage(post, 1200, 630, 'match');
+  const ogImage = coverProxied ? escapeHtml(coverProxied) : null;
Relevance

●● Moderate

Could be a real tenant-specific regression, but depends on whether custom imageProxy is still
supported/required.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
packages/render-helper/src/proxify-image-src.ts defines a module-level `let proxyBase =
'https://i.ecency.com'` that is only mutated by setProxyBase(); the client bundle correctly calls
setProxyBase(imageProxyBase) in apps/self-hosted/src/index.tsx before rendering, but the
hosting-api's post-meta.ts requires the package via createRequire and calls catchPostImage(post,
1200, 630, 'match') directly, with no setProxyBase call anywhere in the file or its imports. The
diff also deletes proxyBaseOf(tenant), which previously read
tenant.config.configuration.general.imageProxy per tenant before building the OG image URL, so
that per-tenant override is now silently dropped for the server-rendered head snippet.

packages/render-helper/src/proxify-image-src.ts[4-63]
apps/self-hosted/src/index.tsx[29-31]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The hosting API's per-post metadata builder generates `og:image`/`twitter:image` via `@ecency/render-helper`'s `catchPostImage()`, but never configures the package's proxy base. render-helper keeps `proxyBase` as module-level mutable state (default `https://i.ecency.com`), mutated only via `setProxyBase()`. The removed code previously read the tenant's `configuration.general.imageProxy` override per request; this behavior is now lost, and any tenant with a custom image proxy gets og:image URLs pointing at the default proxy instead of their configured one.
## Issue Context
- The tenant's configured proxy lives at `tenant.config.configuration.general.imageProxy` (see `apps/self-hosted/hosting/api/src/types.ts` and `tenant-service.ts` defaults).
- `@ecency/render-helper`'s `setProxyBase`/`getProxyBase` mutate module-level state shared by the whole process — this is unsafe for concurrent multi-tenant requests handled by the same Node process, since two tenants' requests could race and get each other's proxy base.
- A safer fix likely requires render-helper to accept an explicit proxy base per call (e.g., an options param) rather than relying on process-global state, or the hosting API must serialize/guard proxy base mutation around each catchPostImage call per tenant.
## Fix Focus Areas
- apps/self-hosted/hosting/api/src/services/post-meta.ts[1-21]
- apps/self-hosted/hosting/api/src/services/post-meta.ts[130-137]
- packages/render-helper/src/proxify-image-src.ts[57-63]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

9. @ecency/render-helper not workspace:* 📘 Rule violation ⚙ Maintainability
Description
apps/self-hosted/hosting/api/package.json adds a dependency on the local workspace package
@ecency/render-helper using a semver range instead of workspace:*. This can cause the app to
resolve a published registry version rather than the in-repo package during workspace installs.
Code

apps/self-hosted/hosting/api/package.json[14]

+    "@ecency/render-helper": "^2.5.26",
Relevance

●●● Strong

Deterministic workspace hygiene; using semver risks pulling registry package instead of local
workspace.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The checklist requires local workspace-to-workspace dependencies to use workspace:*. The hosting
API declares @ecency/render-helper with ^2.5.26 even though the repo contains a local workspace
package packages/render-helper named @ecency/render-helper and the root workspace configuration
includes packages/*.

Rule 2667892: Use workspace:* protocol for local workspace dependencies in package.json
apps/self-hosted/hosting/api/package.json[13-16]
packages/render-helper/package.json[1-4]
package.json[4-7]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A local workspace dependency (`@ecency/render-helper`) is declared with `^2.5.26` instead of `workspace:*`, which violates the workspace dependency policy and can lead to incorrect dependency resolution.
## Issue Context
This repo is a monorepo with `workspaces: ["apps/*", "packages/*"]`, and `packages/render-helper` defines `name: "@ecency/render-helper"`.
## Fix Focus Areas
- apps/self-hosted/hosting/api/package.json[14-15]
- packages/render-helper/package.json[1-4]
- package.json[4-7]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


10. New test file uses .test 📜 Skill insight ⚙ Maintainability
Description
A newly added test file is named seo-files.test.ts rather than the required .spec.ts/.spec.tsx
pattern. This breaks the standardized test naming convention required by the checklist.
Code

apps/self-hosted/hosting/api/src/services/seo-files.test.ts[R1-2]

+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
Relevance

●●● Strong

Simple naming-convention fix for new test file; low risk and typically enforced in repos with
checklists.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The checklist requires test filenames to follow the .spec.ts/.spec.tsx naming pattern. The PR
introduces a new test file under src/services/ named seo-files.test.ts, as shown by the added
test module contents.

apps/self-hosted/hosting/api/src/services/seo-files.test.ts[1-5]
Skill: add-test: Skill: add-test: Skill: add-test: Skill: add-test: Skill: add-test: Skill: add-test: Skill: add-test: Skill: add-test: Skill: add-test: Skill: add-test: Skill: add-test: Skill: add-test: Skill: add-test: Skill: add-test: Skill: add-test: Skill: add-test

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
New test files must use the `.spec.ts` / `.spec.tsx` naming pattern, but this PR adds `seo-files.test.ts`.
## Issue Context
The naming convention is enforced by the compliance checklist and helps tooling discover and organize tests consistently.
## Fix Focus Areas
- apps/self-hosted/hosting/api/src/services/seo-files.test.ts[1-5]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


11. @ecency/render-helper not workspace:* ✗ Dismissed 📘 Rule violation ⚙ Maintainability
Description
apps/self-hosted/hosting/api/package.json adds a dependency on the local workspace package
@ecency/render-helper using a semver range instead of workspace:*. This can cause the app to
resolve a published registry version rather than the in-repo package during workspace installs.
Code

apps/self-hosted/hosting/api/package.json[14]

+    "@ecency/render-helper": "^2.5.26",
Relevance

●●● Strong

Deterministic workspace hygiene; using semver risks pulling registry package instead of local
workspace.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The checklist requires local workspace-to-workspace dependencies to use workspace:*. The hosting
API declares @ecency/render-helper with ^2.5.26 even though the repo contains a local workspace
package packages/render-helper named @ecency/render-helper and the root workspace configuration
includes packages/*.

Rule 2667892: Use workspace:* protocol for local workspace dependencies in package.json
apps/self-hosted/hosting/api/package.json[13-16]
packages/render-helper/package.json[1-4]
package.json[4-7]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A local workspace dependency (`@ecency/render-helper`) is declared with `^2.5.26` instead of `workspace:*`, which violates the workspace dependency policy and can lead to incorrect dependency resolution.
## Issue Context
This repo is a monorepo with `workspaces: ["apps/*", "packages/*"]`, and `packages/render-helper` defines `name: "@ecency/render-helper"`.
## Fix Focus Areas
- apps/self-hosted/hosting/api/package.json[14-15]
- packages/render-helper/package.json[1-4]
- package.json[4-7]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View medium (16)
12. New test file uses .test ✗ Dismissed 📜 Skill insight ⚙ Maintainability
Description
A newly added test file is named seo-files.test.ts rather than the required .spec.ts/.spec.tsx
pattern. This breaks the standardized test naming convention required by the checklist.
Code

apps/self-hosted/hosting/api/src/services/seo-files.test.ts[R1-2]

+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
Relevance

●●● Strong

Simple naming-convention fix for new test file; low risk and typically enforced in repos with
checklists.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The checklist requires test filenames to follow the .spec.ts/.spec.tsx naming pattern. The PR
introduces a new test file under src/services/ named seo-files.test.ts, as shown by the added
test module contents.

apps/self-hosted/hosting/api/src/services/seo-files.test.ts[1-5]
Skill: add-test: Skill: add-test: Skill: add-test: Skill: add-test: Skill: add-test: Skill: add-test: Skill: add-test: Skill: add-test

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
New test files must use the `.spec.ts` / `.spec.tsx` naming pattern, but this PR adds `seo-files.test.ts`.
## Issue Context
The naming convention is enforced by the compliance checklist and helps tooling discover and organize tests consistently.
## Fix Focus Areas
- apps/self-hosted/hosting/api/src/services/seo-files.test.ts[1-5]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


13. @ecency/render-helper not workspace:* ✗ Dismissed 📘 Rule violation ⚙ Maintainability
Description
apps/self-hosted/hosting/api/package.json adds a dependency on the local workspace package
@ecency/render-helper using a semver range instead of workspace:*. This can cause the app to
resolve a published registry version rather than the in-repo package during workspace installs.
Code

apps/self-hosted/hosting/api/package.json[14]

+    "@ecency/render-helper": "^2.5.26",
Relevance

●●● Strong

Deterministic workspace hygiene; using semver risks pulling registry package instead of local
workspace.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The checklist requires local workspace-to-workspace dependencies to use workspace:*. The hosting
API declares @ecency/render-helper with ^2.5.26 even though the repo contains a local workspace
package packages/render-helper named @ecency/render-helper and the root workspace configuration
includes packages/*.

Rule 2667892: Use workspace:* protocol for local workspace dependencies in package.json
apps/self-hosted/hosting/api/package.json[13-16]
packages/render-helper/package.json[1-4]
package.json[4-7]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A local workspace dependency (`@ecency/render-helper`) is declared with `^2.5.26` instead of `workspace:*`, which violates the workspace dependency policy and can lead to incorrect dependency resolution.
## Issue Context
This repo is a monorepo with `workspaces: ["apps/*", "packages/*"]`, and `packages/render-helper` defines `name: "@ecency/render-helper"`.
## Fix Focus Areas
- apps/self-hosted/hosting/api/package.json[14-15]
- packages/render-helper/package.json[1-4]
- package.json[4-7]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


14. New test file uses .test ✗ Dismissed 📜 Skill insight ⚙ Maintainability
Description
A newly added test file is named seo-files.test.ts rather than the required .spec.ts/.spec.tsx
pattern. This breaks the standardized test naming convention required by the checklist.
Code

apps/self-hosted/hosting/api/src/services/seo-files.test.ts[R1-2]

+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
Relevance

●●● Strong

Simple naming-convention fix for new test file; low risk and typically enforced in repos with
checklists.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The checklist requires test filenames to follow the .spec.ts/.spec.tsx naming pattern. The PR
introduces a new test file under src/services/ named seo-files.test.ts, as shown by the added
test module contents.

apps/self-hosted/hosting/api/src/services/seo-files.test.ts[1-5]
Skill: add-test: Skill: add-test: Skill: add-test: Skill: add-test

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
New test files must use the `.spec.ts` / `.spec.tsx` naming pattern, but this PR adds `seo-files.test.ts`.
## Issue Context
The naming convention is enforced by the compliance checklist and helps tooling discover and organize tests consistently.
## Fix Focus Areas
- apps/self-hosted/hosting/api/src/services/seo-files.test.ts[1-5]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


15. @ecency/render-helper not workspace:* ✗ Dismissed 📘 Rule violation ⚙ Maintainability
Description
apps/self-hosted/hosting/api/package.json adds a dependency on the local workspace package
@ecency/render-helper using a semver range instead of workspace:*. This can cause the app to
resolve a published registry version rather than the in-repo package during workspace installs.
Code

apps/self-hosted/hosting/api/package.json[14]

+    "@ecency/render-helper": "^2.5.26",
Relevance

●●● Strong

Deterministic workspace hygiene; using semver risks pulling registry package instead of local
workspace.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The checklist requires local workspace-to-workspace dependencies to use workspace:*. The hosting
API declares @ecency/render-helper with ^2.5.26 even though the repo contains a local workspace
package packages/render-helper named @ecency/render-helper and the root workspace configuration
includes packages/*.

Rule 2667892: Use workspace:* protocol for local workspace dependencies in package.json
apps/self-hosted/hosting/api/package.json[13-16]
packages/render-helper/package.json[1-4]
package.json[4-7]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A local workspace dependency (`@ecency/render-helper`) is declared with `^2.5.26` instead of `workspace:*`, which violates the workspace dependency policy and can lead to incorrect dependency resolution.
## Issue Context
This repo is a monorepo with `workspaces: ["apps/*", "packages/*"]`, and `packages/render-helper` defines `name: "@ecency/render-helper"`.
## Fix Focus Areas
- apps/self-hosted/hosting/api/package.json[14-15]
- packages/render-helper/package.json[1-4]
- package.json[4-7]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


16. New test file uses .test ✗ Dismissed 📜 Skill insight ⚙ Maintainability
Description
A newly added test file is named seo-files.test.ts rather than the required .spec.ts/.spec.tsx
pattern. This breaks the standardized test naming convention required by the checklist.
Code

apps/self-hosted/hosting/api/src/services/seo-files.test.ts[R1-2]

+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
Relevance

●●● Strong

Simple naming-convention fix for new test file; low risk and typically enforced in repos with
checklists.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The checklist requires test filenames to follow the .spec.ts/.spec.tsx naming pattern. The PR
introduces a new test file under src/services/ named seo-files.test.ts, as shown by the added
test module contents.

apps/self-hosted/hosting/api/src/services/seo-files.test.ts[1-5]
Skill: add-test: Skill: add-test

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
New test files must use the `.spec.ts` / `.spec.tsx` naming pattern, but this PR adds `seo-files.test.ts`.
## Issue Context
The naming convention is enforced by the compliance checklist and helps tooling discover and organize tests consistently.
## Fix Focus Areas
- apps/self-hosted/hosting/api/src/services/seo-files.test.ts[1-5]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


17. SEO RPCs serialize sync ✓ Resolved 🐞 Bug ➹ Performance
Description
syncAllConfigs awaits each stale tenant’s SEO RPC inside its sequential loop, so one slow call
delays config repair and SEO generation for every later tenant by up to 10 seconds. During bridge
degradation, a pass grows toward stale_tenant_count × 10 seconds while the single-flight scheduler
suppresses later passes.
Code

apps/self-hosted/hosting/api/src/services/config-service.ts[370]

+          await this.writeSeoFilesIfStale(fresh);
Relevance

●● Moderate

Parallelizing tenant sync affects load/locking; performance change is subjective without explicit
precedent.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The tenant loop awaits SEO generation one tenant at a time, each bridge call has a 10-second bound,
and the scheduler refuses to start another pass while one is running.

apps/self-hosted/hosting/api/src/services/config-service.ts[352-379]
apps/self-hosted/hosting/api/src/services/seo-files.ts[46-51]
apps/self-hosted/hosting/api/src/services/seo-files.ts[77-113]
apps/self-hosted/hosting/api/src/index.ts[121-135]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Network-bound SEO generation now runs serially inside the global config reconciliation loop, causing latency to accumulate across stale tenants.
## Issue Context
The current tenant’s config is written first, but every later tenant and the cleanup sweep wait behind each stale tenant’s bridge call. Preserve per-tenant locking while moving SEO work out of the serial config path or processing it with bounded concurrency.
## Fix Focus Areas
- apps/self-hosted/hosting/api/src/services/config-service.ts[352-379]
- apps/self-hosted/hosting/api/src/services/seo-files.ts[50-51]
- apps/self-hosted/hosting/api/src/services/seo-files.ts[77-91]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


18. Domain changes leave stale URLs ✓ Resolved 🐞 Bug ≡ Correctness
Description
SEO freshness depends only on mtime, so verifying or removing a custom domain does not invalidate
files whose embedded blog URL changed. Robots, sitemap, and RSS can therefore advertise the previous
host for up to 30 minutes after a domain transition.
Code

apps/self-hosted/hosting/api/src/services/config-service.ts[R224-226]

+        const stat = await fs.stat(p);
+        if (Date.now() - stat.mtimeMs > SEO_FRESH_MS) {
+          fresh = false;
Relevance

●● Moderate

Plausible bug but requires semantic decision about SEO invalidation on domain transitions; no clear
precedent found.

PR-#1310

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
All three builders derive URLs from TenantService.getBlogUrl, but freshness only checks mtimes and
the domain mutation routes do not invalidate or regenerate the SEO files.

apps/self-hosted/hosting/api/src/services/config-service.ts[217-247]
apps/self-hosted/hosting/api/src/services/seo-files.ts[121-158]
apps/self-hosted/hosting/api/src/services/seo-files.ts[170-208]
apps/self-hosted/hosting/api/src/routes/domains.ts[179-225]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Domain-dependent SEO files remain fresh by mtime even when the tenant’s served domain changes, leaving stale hostnames in crawler and feed output.
## Issue Context
Invalidate or regenerate SEO files when a domain is verified, replaced, or removed. Alternatively, include the effective blog URL in a persisted freshness fingerprint instead of relying on mtime alone.
## Fix Focus Areas
- apps/self-hosted/hosting/api/src/services/config-service.ts[217-247]
- apps/self-hosted/hosting/api/src/routes/domains.ts[179-205]
- apps/self-hosted/hosting/api/src/routes/domains.ts[208-225]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


19. Control characters break RSS ✓ Resolved 🐞 Bug ≡ Correctness
Description
RSS chain and owner text is escaped for markup delimiters but XML 1.0-forbidden controls such as
U+0000 remain unchanged. One such character in a title, description, or body excerpt makes the
entire rss.xml document not well-formed.
Code

apps/self-hosted/hosting/api/src/services/seo-files.ts[195]

+      `      <description>${escapeHtml(excerptOf(post.body))}</description>`,
Relevance

●● Moderate

Edge-case correctness fix; may be seen as over-hardening vs current escapeHtml usage without prior
precedent.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The RSS builder interpolates authored text through escapeHtml, while that helper only replaces five
markup characters and does not validate XML code points.

apps/self-hosted/hosting/api/src/services/seo-files.ts[170-207]
apps/self-hosted/hosting/api/src/utils/escape-html.ts[1-9]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The RSS builder can emit XML 1.0-forbidden control characters from owner- or chain-authored strings, invalidating the complete feed.
## Issue Context
Apply an XML-specific sanitizer that removes or replaces invalid code points before entity escaping, and cover metadata, item titles, and excerpts with regression tests.
## Fix Focus Areas
- apps/self-hosted/hosting/api/src/services/seo-files.ts[170-207]
- apps/self-hosted/hosting/api/src/utils/escape-html.ts[1-9]
- apps/self-hosted/hosting/api/src/services/seo-files.test.ts[71-81]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


20. SEO files publish non-atomically ✓ Resolved 🐞 Bug ☼ Reliability
Description
The new sitemap and RSS files are written directly over paths nginx serves concurrently, allowing a
request during fs.writeFile to observe a truncated or partially rewritten XML document. Publish
through a same-dire...

@qodo-code-review

qodo-code-review Bot commented Aug 12, 2026

Copy link
Copy Markdown

PR Summary by Qodo

Self-hosted: static SEO files, canonical policy, and responsive media srcset

✨ Enhancement 🧪 Tests ⚙️ Configuration changes 🕐 40+ Minutes

Grey Divider

AI Description

• Generate per-tenant robots.txt, sitemap.xml, and rss.xml during sync; serve statically via nginx.
• Enforce canonical URL policy (custom domains self-canonicalize; subdomains point to ecency.com
 SSR).
• Add responsive image srcset/sizes to post cards and fix OG image proxying via render-helper.
Diagram

graph TD
  A["Hosting API"] --> B["seo-files builders"] --> C{{"Hive RPC"}}
  A --> D[("Configs volume")] --> E["nginx"] --> G(["Crawlers/readers"])
  E --> F(["Self-hosted SPA"])

  subgraph Legend
    direction LR
    _svc["Service"] ~~~ _db[("Volume / disk")] ~~~ _ext{{"External"}} ~~~ _cli(["Client"])
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Request-time SEO endpoints with caching
  • ➕ Always up-to-date without relying on mtime freshness checks
  • ➕ No need to manage on-disk per-tenant files or cleanup
  • ➖ Introduces request-time compute and failure modes (RPC latency/availability)
  • ➖ Harder to guarantee crawler responsiveness under load; violates the 'static-only' constraint
2. Separate scheduled job (cron) for SEO generation
  • ➕ Decouples SEO generation from config sync cadence/concerns
  • ➕ Easier to tune frequency independently of config publishing
  • ➖ Adds additional operational surface area and deployment complexity
  • ➖ Still needs per-tenant locking and deletion/reconcile integration

Recommendation: Keep the PR’s approach: generating static SEO artifacts during the existing sync pass best matches the explicit goal of zero request-time chain/RPC dependency while reusing established per-tenant locking, deletion, and reconcile behavior. The mtime-based freshness window + touch is a pragmatic way to bound RPC cost without sacrificing staleness guarantees.

Files changed (17) +576 / -64

Enhancement (8) +357 / -58
config-service.tsWrite static SEO files on sync and inject canonical/RSS links +80/-10

Write static SEO files on sync and inject canonical/RSS links

• Moves escapeHtml into a shared util and re-exports it for compatibility. Adds per-tenant robots/sitemap/rss path helpers and a staleness-based SEO regeneration step during sync (with mtime touch after write-if-changed). Updates the SSI meta snippet to include canonical policy and RSS auto-discovery link, and ensures deletion/reconcile includes the new SEO artifacts.

apps/self-hosted/hosting/api/src/services/config-service.ts

post-meta.tsUse render-helper cover extraction and canonical policy for post meta +26/-43

Use render-helper cover extraction and canonical policy for post meta

• Switches OG cover selection/proxying to render-helper’s catchPostImage (loaded via CJS require due to Node ESM directory-import issues). Extracts excerpt logic into a shared util and adds rel=canonical for posts using the same custom-domain vs subdomain canonical policy as the tenant snippet.

apps/self-hosted/hosting/api/src/services/post-meta.ts

seo-files.tsImplement per-tenant robots/sitemap/rss builders and canonical helpers +213/-0

Implement per-tenant robots/sitemap/rss builders and canonical helpers

• Adds canonicalHomeUrl/canonicalPostUrl policy, bounded RPC fetchTenantPosts (blog vs community feed), and builders for robots.txt, sitemap.xml (W3C lastmod), and RSS 2.0 (RFC822 pubDate, atom self-link, escaped excerpt). Designed for static generation during sync, not request-time compute.

apps/self-hosted/hosting/api/src/services/seo-files.ts

blog-post-item.tsxAdd srcset/sizes to post card images +14/-0

Add srcset/sizes to post card images

• Uses render-helper’s buildSrcSet to emit responsive variants for card images, with layout-specific sizes for grid and list cards. Reduces unnecessary large image downloads on small screens without affecting CLS constraints.

apps/self-hosted/src/features/blog/components/blog-post-item.tsx

blog-navigation.tsxRoute RSS link to self-hosted feed when managed +5/-1

Route RSS link to self-hosted feed when managed

• Determines whether the instance is managed from runtime config and passes it into getRssFeedUrl so the navigation RSS icon points at /rss.xml for managed hosting.

apps/self-hosted/src/features/blog/layout/blog-navigation.tsx

index.tsxUpdate RSS auto-discovery to prefer /rss.xml on managed instances +6/-1

Update RSS auto-discovery to prefer /rss.xml on managed instances

• Extends the RSS auto-discovery link injection to call getRssFeedUrl with the managed flag, ensuring crawlers and readers can discover the instance’s own feed when it exists.

apps/self-hosted/src/index.tsx

journal-post-card.tsxAdd responsive srcset for Journal thumbnail +3/-0

Add responsive srcset for Journal thumbnail

• Adds srcSet/sizes for the Journal side thumbnail via buildSrcSet, so the fixed-size thumbnail loads an appropriately sized image across devices.

apps/self-hosted/src/themes/journal/journal-post-card.tsx

rss-feed-url.tsSupport managed instances serving local /rss.xml +10/-3

Support managed instances serving local /rss.xml

• Extends getRssFeedUrl with an optional managed flag; when true (and window exists), returns the tenant’s own /rss.xml to match newly generated static feeds. Preserves ecency.com fallback behavior for unmanaged/self-hosted deployments.

apps/self-hosted/src/utils/rss-feed-url.ts

Refactor (2) +24 / -0
escape-html.tsExtract HTML/XML escaping into shared util +9/-0

Extract HTML/XML escaping into shared util

• Adds a small escapeHtml helper to centralize escaping logic for both config meta and SEO file generation, avoiding circular dependencies.

apps/self-hosted/hosting/api/src/utils/escape-html.ts

excerpt.tsExtract excerpt builder for RSS/post-meta reuse +15/-0

Extract excerpt builder for RSS/post-meta reuse

• Moves markdown/HTML stripping and truncation logic into a shared excerptOf utility so RSS descriptions and post-meta descriptions use consistent sanitization.

apps/self-hosted/hosting/api/src/utils/excerpt.ts

Tests (5) +167 / -3
config-publish-lock.test.tsExtend TenantService mock for RSS/canonical usage +4/-1

Extend TenantService mock for RSS/canonical usage

• Updates the TenantService mock to include getBlogUrl so meta/SEO builders can run in tests without pulling real tenant URL logic.

apps/self-hosted/hosting/api/src/services/config-publish-lock.test.ts

hivesigner-registry.test.tsUpdate TenantService mock to support new URL lookup +1/-0

Update TenantService mock to support new URL lookup

• Adds getBlogUrl to the TenantService mock used by the hivesigner registry tests to satisfy new builder dependencies.

apps/self-hosted/hosting/api/src/services/hivesigner-registry.test.ts

post-meta.test.tsAssert modern proxied og:image URL shape +4/-2

Assert modern proxied og:image URL shape

• Updates the OG image expectation to match render-helper’s modern hashed /p/ proxy path (with explicit width/height params) instead of the legacy redirecting sized route.

apps/self-hosted/hosting/api/src/services/post-meta.test.ts

seo-files.test.tsAdd SEO builders and canonical policy test coverage +142/-0

Add SEO builders and canonical policy test coverage

• Introduces tests for robots/sitemap/rss builders (escaping, date formats, self-link) and for canonical URL behavior across custom-domain vs subdomain tenants. Adds fetchTenantPosts tests for blog vs community RPC paging and malformed response handling.

apps/self-hosted/hosting/api/src/services/seo-files.test.ts

rss-feed-url.test.tsAdd managed-instance RSS routing tests +16/-0

Add managed-instance RSS routing tests

• Marks the suite as jsdom and adds coverage ensuring managed instances return '${origin}/rss.xml', while unmanaged instances continue pointing at ecency.com RSS URLs.

apps/self-hosted/src/utils/rss-feed-url.test.ts

Other (2) +28 / -3
package.jsonAdd render-helper and utilities for SEO generation +4/-3

Add render-helper and utilities for SEO generation

• Introduces @ecency/render-helper as a runtime dependency in the hosting API, alongside date-fns and nanoid (ordering adjusted). Enables server-side cover extraction/proxy URL generation and SEO builders/tests.

apps/self-hosted/hosting/api/package.json

nginx-multi-tenant.confServe per-tenant robots.txt, sitemap.xml, and rss.xml via try_files +24/-0

Serve per-tenant robots.txt, sitemap.xml, and rss.xml via try_files

• Adds explicit locations for /robots.txt, /sitemap.xml, and /rss.xml in both server blocks (subdomain and custom-domain routing). robots falls back to a generic baked-in file; sitemap and rss 404 until generated by the sync pass.

apps/self-hosted/hosting/nginx-multi-tenant.conf

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 620b83920d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

const fresh = await TenantService.getByUsername(tenant.username);
if (!fresh || !isPublishableTenant(fresh)) return;
await this.writeConfigFile(fresh);
await this.writeSeoFilesIfStale(fresh);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Decouple SEO RPC waits from the serial config pass

When the Hive RPC is slow or unavailable, every stale tenant can block here for the full 10-second SEO timeout while syncAllConfigs processes tenants sequentially. Tenants later in the list therefore wait up to tenant count × 10s for config publication and stale-file cleanup, and the single-flight guard in src/index.ts skips all five-minute retries while that pass remains active. Run SEO refreshes separately or with bounded concurrency so an external feed outage cannot stall the existing config reconciliation path.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in d9317ff: the SEO refresh runs on its own five-minute loop with its own single flight and a four-worker pool, fully decoupled from the serial config pass, so an RPC outage can no longer delay config publication and cleanup.

` <link>${link}</link>`,
` <guid isPermaLink="true">${link}</guid>`,
...(pubDate ? [` <pubDate>${pubDate}</pubDate>`] : []),
` <description>${escapeHtml(excerptOf(post.body))}</description>`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Sanitize XML-invalid characters before writing RSS

For a post whose chain-authored body contains an XML 1.0-disallowed control character such as \u000b, escapeHtml leaves that character intact because it only replaces markup metacharacters. The resulting rss.xml is not well-formed, so one malicious or malformed recent post can make feed readers reject the entire tenant or community feed; strip or replace XML-invalid code points from titles, descriptions, and excerpts before interpolation.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in d9317ff: escapeHtml removes XML 1.0-invalid code points (and lone surrogates) before entity escaping, covering every interpolated field in the feed, the sitemap and the meta snippets. Tests pin U+0000, U+000B and non-characters while keeping tab and newline.

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@feruzm, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 43 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 8c0066d9-356d-48b9-ba87-f5b03331232b

📥 Commits

Reviewing files that changed from the base of the PR and between b49d9d1 and 5cd3c75.

📒 Files selected for processing (10)
  • apps/self-hosted/hosting/api/scripts/generate-seo.ts
  • apps/self-hosted/hosting/api/src/services/post-meta.test.ts
  • apps/self-hosted/hosting/api/src/services/post-meta.ts
  • apps/self-hosted/hosting/api/src/utils/escape-html.ts
  • apps/self-hosted/src/features/blog/components/blog-post-item.tsx
  • apps/self-hosted/src/themes/grid-sizes.test.ts
  • apps/self-hosted/src/themes/grid-sizes.ts
  • apps/self-hosted/src/themes/use-theme-components.ts
  • apps/self-hosted/src/utils/rss-feed-url.test.ts
  • apps/self-hosted/src/utils/rss-feed-url.ts
📝 Walkthrough

Walkthrough

The change adds static tenant SEO files, tenant-aware canonical metadata, managed RSS routing, responsive post images, and shared escaping and excerpt utilities.

Changes

Self-hosted discovery and media

Layer / File(s) Summary
SEO generation and content utilities
apps/self-hosted/hosting/api/src/services/seo-files.ts, apps/self-hosted/hosting/api/src/utils/*, apps/self-hosted/hosting/api/src/services/seo-files.test.ts
The API fetches bounded tenant posts and generates escaped robots.txt, sitemap.xml, and rss.xml content with canonical URLs, dates, excerpts, and validation.
Tenant SEO artifact lifecycle
apps/self-hosted/hosting/api/src/services/config-service.ts, apps/self-hosted/hosting/api/src/index.ts, apps/self-hosted/hosting/nginx-multi-tenant.conf, apps/self-hosted/hosting/api/src/services/*test.ts
Config synchronization atomically writes, refreshes, discovers, and deletes SEO files. The worker processes publishable tenants periodically. Nginx serves wildcard and custom-domain artifacts.
Independent deployment SEO generation
apps/self-hosted/hosting/api/scripts/generate-seo.ts, apps/self-hosted/hosting/api/Dockerfile, apps/self-hosted/hosting/api/package.json, apps/self-hosted/DEPLOYMENT.md
The hosting API image includes a CLI that validates inputs and atomically generates SEO files for independent deployments. Deployment documentation describes generation, mounting, and configuration.
Canonical metadata, media, and RSS routing
apps/self-hosted/hosting/api/src/services/post-meta.ts, apps/self-hosted/src/features/blog/..., apps/self-hosted/src/themes/journal/..., apps/self-hosted/src/utils/rss-feed-url.ts, apps/self-hosted/src/index.tsx, apps/self-hosted/src/core/configuration-loader.ts
Post metadata uses shared canonical, excerpt, and image utilities. Blog cards provide responsive image sources. Managed instances use configured or local RSS feeds, with tests covering URL precedence and fallbacks.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Browser
  participant Nginx
  participant SeoSyncWorker
  participant ConfigService
  participant HiveRPC
  Browser->>Nginx: request tenant /rss.xml or /sitemap.xml
  SeoSyncWorker->>ConfigService: synchronize stale tenant artifacts
  ConfigService->>HiveRPC: fetch bounded tenant posts
  HiveRPC-->>ConfigService: return post records
  ConfigService-->>Nginx: publish static SEO artifact
  Nginx-->>Browser: return tenant SEO file
Loading

Poem

A rabbit writes feeds in the night,
Three tidy files glow in the light.
Canonical paths guide every hop,
Responsive pictures never stop.
Local RSS springs from the site—
The burrow’s metadata is bright.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The implementation meets the linked issues' static-only SEO and responsive-image coding goals, but it does not provide the requested research notes and reviewed implementation plan. Add the required research note and implementation plan, or link evidence that the research and plan were reviewed before implementation.
Docstring Coverage ⚠️ Warning Docstring coverage is 57.14% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the PR's main changes: static SEO files, canonical URL handling, and responsive media.
Out of Scope Changes check ✅ Passed The changes remain within the linked objectives for static SEO, canonical URLs, RSS routing, image handling, deployment, and supporting tests.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/self-hosted-seo-media

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/self-hosted/hosting/api/src/services/seo-files.ts`:
- Around line 115-118: Update the type guard in the raw-record filter to require
created to be a string and, when present, updated to also be a string before
returning TenantPost; otherwise normalize those fields to valid values before
return. Add coverage for malformed date fields mixed into an otherwise valid RPC
response, ensuring SEO sync does not pass non-strings to lastmodOf or pubDateOf.
- Around line 77-113: Update bounded and fetchTenantPosts so each SEO RPC uses
an AbortController created before the call. Pass RPC_TIMEOUT_MS as the third
callRPC argument and the controller.signal as the fifth argument, then abort the
controller when the bounded timeout expires while preserving the existing
timeout rejection behavior.

In `@apps/self-hosted/hosting/api/src/utils/escape-html.ts`:
- Around line 2-8: Update escapeHtml to remove XML 1.0-invalid code points,
including control characters and lone surrogates, before applying the existing
entity replacements. Preserve valid Unicode characters and the current escaping
behavior for &, <, >, double quotes, and apostrophes.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: a1fcd3c0-709b-4edd-aec5-f0465862c52f

📥 Commits

Reviewing files that changed from the base of the PR and between 3bde040 and 620b839.

⛔ Files ignored due to path filters (1)
  • apps/self-hosted/hosting/api/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (17)
  • apps/self-hosted/hosting/api/package.json
  • apps/self-hosted/hosting/api/src/services/config-publish-lock.test.ts
  • apps/self-hosted/hosting/api/src/services/config-service.ts
  • apps/self-hosted/hosting/api/src/services/hivesigner-registry.test.ts
  • apps/self-hosted/hosting/api/src/services/post-meta.test.ts
  • apps/self-hosted/hosting/api/src/services/post-meta.ts
  • apps/self-hosted/hosting/api/src/services/seo-files.test.ts
  • apps/self-hosted/hosting/api/src/services/seo-files.ts
  • apps/self-hosted/hosting/api/src/utils/escape-html.ts
  • apps/self-hosted/hosting/api/src/utils/excerpt.ts
  • apps/self-hosted/hosting/nginx-multi-tenant.conf
  • apps/self-hosted/src/features/blog/components/blog-post-item.tsx
  • apps/self-hosted/src/features/blog/layout/blog-navigation.tsx
  • apps/self-hosted/src/index.tsx
  • apps/self-hosted/src/themes/journal/journal-post-card.tsx
  • apps/self-hosted/src/utils/rss-feed-url.test.ts
  • apps/self-hosted/src/utils/rss-feed-url.ts

Comment thread apps/self-hosted/hosting/api/src/services/seo-files.ts Outdated
Comment thread apps/self-hosted/hosting/api/src/services/seo-files.ts Outdated
Comment thread apps/self-hosted/hosting/api/src/utils/escape-html.ts
@qodo-code-review

qodo-code-review Bot commented Aug 12, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (1) 📜 Skill insights (0)

Grey Divider


Action required

1. Malformed response erases posts ✓ Resolved 🐞 Bug ≡ Correctness
Description
fetchTenantPosts treats a malformed non-array bridge response as a successful empty feed, so
regeneration overwrites valid sitemap/RSS post entries and marks the empty files fresh for 30
minutes. Reject malformed responses instead so the last known-good files remain served.
Code

apps/self-hosted/hosting/api/src/services/seo-files.ts[114]

+  if (!Array.isArray(raw)) return [];
Relevance

●● Moderate

Behavior change conflicts with newly added test; team may prefer current fail-open semantics.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The malformed value becomes [], after which the config service builds files from that empty list
and touches them; the new test explicitly locks in this destructive behavior.

apps/self-hosted/hosting/api/src/services/seo-files.ts[94-119]
apps/self-hosted/hosting/api/src/services/config-service.ts[236-247]
apps/self-hosted/hosting/api/src/services/seo-files.test.ts[138-140]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A non-array bridge response is converted to an empty post list, causing valid sitemap and RSS files to be overwritten and treated as fresh.

## Issue Context
A legitimate empty feed is represented by an empty array; a non-array value is a malformed upstream response and should follow the same failure path as a rejected or timed-out RPC so existing files remain intact.

## Fix Focus Areas
- apps/self-hosted/hosting/api/src/services/seo-files.ts[114-118]
- apps/self-hosted/hosting/api/src/services/config-service.ts[238-247]
- apps/self-hosted/hosting/api/src/services/seo-files.test.ts[138-140]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. render-helper cover proxy ignores tenant image proxy ✓ Resolved 🐞 Bug ≡ Correctness
Description
post-meta.ts now generates the per-post og:image via render-helper's catchPostImage() without ever
calling setProxyBase(), so the module-level proxyBase in @ecency/render-helper stays at its default
'https://i.ecency.com' for every tenant. The removed code previously read
tenant.config.configuration.general.imageProxy per request via proxyBaseOf(tenant); tenants who
configured a custom image proxy now get og:image URLs pointing at the wrong host.
Code

apps/self-hosted/hosting/api/src/services/post-meta.ts[R132-137]

+  // render-helper's own cover extraction and proxying, the same pipeline the
+  // apps render with: it understands string-form metadata, entities and code
+  // fences, and emits the modern proxy path instead of the legacy sized
+  // route that answers with a redirect. Chain-authored, so escaped.
+  const coverProxied = catchPostImage(post, 1200, 630, 'match');
+  const ogImage = coverProxied ? escapeHtml(coverProxied) : null;
Relevance

●● Moderate

Could be a real tenant-specific regression, but depends on whether custom imageProxy is still
supported/required.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
packages/render-helper/src/proxify-image-src.ts defines a module-level `let proxyBase =
'https://i.ecency.com'` that is only mutated by setProxyBase(); the client bundle correctly calls
setProxyBase(imageProxyBase) in apps/self-hosted/src/index.tsx before rendering, but the
hosting-api's post-meta.ts requires the package via createRequire and calls catchPostImage(post,
1200, 630, 'match') directly, with no setProxyBase call anywhere in the file or its imports. The
diff also deletes proxyBaseOf(tenant), which previously read
tenant.config.configuration.general.imageProxy per tenant before building the OG image URL, so
that per-tenant override is now silently dropped for the server-rendered head snippet.

packages/render-helper/src/proxify-image-src.ts[4-63]
apps/self-hosted/src/index.tsx[29-31]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The hosting API's per-post metadata builder generates `og:image`/`twitter:image` via `@ecency/render-helper`'s `catchPostImage()`, but never configures the package's proxy base. render-helper keeps `proxyBase` as module-level mutable state (default `https://i.ecency.com`), mutated only via `setProxyBase()`. The removed code previously read the tenant's `configuration.general.imageProxy` override per request; this behavior is now lost, and any tenant with a custom image proxy gets og:image URLs pointing at the default proxy instead of their configured one.

## Issue Context
- The tenant's configured proxy lives at `tenant.config.configuration.general.imageProxy` (see `apps/self-hosted/hosting/api/src/types.ts` and `tenant-service.ts` defaults).
- `@ecency/render-helper`'s `setProxyBase`/`getProxyBase` mutate module-level state shared by the whole process — this is unsafe for concurrent multi-tenant requests handled by the same Node process, since two tenants' requests could race and get each other's proxy base.
- A safer fix likely requires render-helper to accept an explicit proxy base per call (e.g., an options param) rather than relying on process-global state, or the hosting API must serialize/guard proxy base mutation around each catchPostImage call per tenant.

## Fix Focus Areas
- apps/self-hosted/hosting/api/src/services/post-meta.ts[1-21]
- apps/self-hosted/hosting/api/src/services/post-meta.ts[130-137]
- packages/render-helper/src/proxify-image-src.ts[57-63]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

3. @ecency/render-helper not workspace:* ✗ Dismissed 📘 Rule violation ⚙ Maintainability
Description
apps/self-hosted/hosting/api/package.json adds a dependency on the local workspace package
@ecency/render-helper using a semver range instead of workspace:*. This can cause the app to
resolve a published registry version rather than the in-repo package during workspace installs.
Code

apps/self-hosted/hosting/api/package.json[14]

+    "@ecency/render-helper": "^2.5.26",
Relevance

●●● Strong

Deterministic workspace hygiene; using semver risks pulling registry package instead of local
workspace.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The checklist requires local workspace-to-workspace dependencies to use workspace:*. The hosting
API declares @ecency/render-helper with ^2.5.26 even though the repo contains a local workspace
package packages/render-helper named @ecency/render-helper and the root workspace configuration
includes packages/*.

Rule 2667892: Use workspace:* protocol for local workspace dependencies in package.json
apps/self-hosted/hosting/api/package.json[13-16]
packages/render-helper/package.json[1-4]
package.json[4-7]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A local workspace dependency (`@ecency/render-helper`) is declared with `^2.5.26` instead of `workspace:*`, which violates the workspace dependency policy and can lead to incorrect dependency resolution.

## Issue Context
This repo is a monorepo with `workspaces: ["apps/*", "packages/*"]`, and `packages/render-helper` defines `name: "@ecency/render-helper"`.

## Fix Focus Areas
- apps/self-hosted/hosting/api/package.json[14-15]
- packages/render-helper/package.json[1-4]
- package.json[4-7]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


4. New test file uses .test ✗ Dismissed 📜 Skill insight ⚙ Maintainability
Description
A newly added test file is named seo-files.test.ts rather than the required .spec.ts/.spec.tsx
pattern. This breaks the standardized test naming convention required by the checklist.
Code

apps/self-hosted/hosting/api/src/services/seo-files.test.ts[R1-2]

+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
Relevance

●●● Strong

Simple naming-convention fix for new test file; low risk and typically enforced in repos with
checklists.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The checklist requires test filenames to follow the .spec.ts/.spec.tsx naming pattern. The PR
introduces a new test file under src/services/ named seo-files.test.ts, as shown by the added
test module contents.

apps/self-hosted/hosting/api/src/services/seo-files.test.ts[1-5]
Skill: add-test

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
New test files must use the `.spec.ts` / `.spec.tsx` naming pattern, but this PR adds `seo-files.test.ts`.

## Issue Context
The naming convention is enforced by the compliance checklist and helps tooling discover and organize tests consistently.

## Fix Focus Areas
- apps/self-hosted/hosting/api/src/services/seo-files.test.ts[1-5]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. SEO RPCs serialize sync ✓ Resolved 🐞 Bug ➹ Performance
Description
syncAllConfigs awaits each stale tenant’s SEO RPC inside its sequential loop, so one slow call
delays config repair and SEO generation for every later tenant by up to 10 seconds. During bridge
degradation, a pass grows toward stale_tenant_count × 10 seconds while the single-flight scheduler
suppresses later passes.
Code

apps/self-hosted/hosting/api/src/services/config-service.ts[370]

+          await this.writeSeoFilesIfStale(fresh);
Relevance

●● Moderate

Parallelizing tenant sync affects load/locking; performance change is subjective without explicit
precedent.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The tenant loop awaits SEO generation one tenant at a time, each bridge call has a 10-second bound,
and the scheduler refuses to start another pass while one is running.

apps/self-hosted/hosting/api/src/services/config-service.ts[352-379]
apps/self-hosted/hosting/api/src/services/seo-files.ts[46-51]
apps/self-hosted/hosting/api/src/services/seo-files.ts[77-113]
apps/self-hosted/hosting/api/src/index.ts[121-135]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Network-bound SEO generation now runs serially inside the global config reconciliation loop, causing latency to accumulate across stale tenants.

## Issue Context
The current tenant’s config is written first, but every later tenant and the cleanup sweep wait behind each stale tenant’s bridge call. Preserve per-tenant locking while moving SEO work out of the serial config path or processing it with bounded concurrency.

## Fix Focus Areas
- apps/self-hosted/hosting/api/src/services/config-service.ts[352-379]
- apps/self-hosted/hosting/api/src/services/seo-files.ts[50-51]
- apps/self-hosted/hosting/api/src/services/seo-files.ts[77-91]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View medium (3)
6. Control characters break RSS ✓ Resolved 🐞 Bug ≡ Correctness
Description
RSS chain and owner text is escaped for markup delimiters but XML 1.0-forbidden controls such as
U+0000 remain unchanged. One such character in a title, description, or body excerpt makes the
entire rss.xml document not well-formed.
Code

apps/self-hosted/hosting/api/src/services/seo-files.ts[195]

+      `      <description>${escapeHtml(excerptOf(post.body))}</description>`,
Relevance

●● Moderate

Edge-case correctness fix; may be seen as over-hardening vs current escapeHtml usage without prior
precedent.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The RSS builder interpolates authored text through escapeHtml, while that helper only replaces five
markup characters and does not validate XML code points.

apps/self-hosted/hosting/api/src/services/seo-files.ts[170-207]
apps/self-hosted/hosting/api/src/utils/escape-html.ts[1-9]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The RSS builder can emit XML 1.0-forbidden control characters from owner- or chain-authored strings, invalidating the complete feed.

## Issue Context
Apply an XML-specific sanitizer that removes or replaces invalid code points before entity escaping, and cover metadata, item titles, and excerpts with regression tests.

## Fix Focus Areas
- apps/self-hosted/hosting/api/src/services/seo-files.ts[170-207]
- apps/self-hosted/hosting/api/src/utils/escape-html.ts[1-9]
- apps/self-hosted/hosting/api/src/services/seo-files.test.ts[71-81]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


7. Domain changes leave stale URLs ✓ Resolved 🐞 Bug ≡ Correctness
Description
SEO freshness depends only on mtime, so verifying or removing a custom domain does not invalidate
files whose embedded blog URL changed. Robots, sitemap, and RSS can therefore advertise the previous
host for up to 30 minutes after a domain transition.
Code

apps/self-hosted/hosting/api/src/services/config-service.ts[R224-226]

+        const stat = await fs.stat(p);
+        if (Date.now() - stat.mtimeMs > SEO_FRESH_MS) {
+          fresh = false;
Relevance

●● Moderate

Plausible bug but requires semantic decision about SEO invalidation on domain transitions; no clear
precedent found.

PR-#1310

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
All three builders derive URLs from TenantService.getBlogUrl, but freshness only checks mtimes and
the domain mutation routes do not invalidate or regenerate the SEO files.

apps/self-hosted/hosting/api/src/services/config-service.ts[217-247]
apps/self-hosted/hosting/api/src/services/seo-files.ts[121-158]
apps/self-hosted/hosting/api/src/services/seo-files.ts[170-208]
apps/self-hosted/hosting/api/src/routes/domains.ts[179-225]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Domain-dependent SEO files remain fresh by mtime even when the tenant’s served domain changes, leaving stale hostnames in crawler and feed output.

## Issue Context
Invalidate or regenerate SEO files when a domain is verified, replaced, or removed. Alternatively, include the effective blog URL in a persisted freshness fingerprint instead of relying on mtime alone.

## Fix Focus Areas
- apps/self-hosted/hosting/api/src/services/config-service.ts[217-247]
- apps/self-hosted/hosting/api/src/routes/domains.ts[179-205]
- apps/self-hosted/hosting/api/src/routes/domains.ts[208-225]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


8. SEO files publish non-atomically ✓ Resolved 🐞 Bug ☼ Reliability
Description
The new sitemap and RSS files are written directly over paths nginx serves concurrently, allowing a
request during fs.writeFile to observe a truncated or partially rewritten XML document. Publish
through a same-directory temporary file and atomic rename so readers always receive a complete old
or new version.
Code

apps/self-hosted/hosting/api/src/services/config-service.ts[R241-242]

+    await this.writeIfChanged(paths.sitemap, buildSitemapXml(tenant, posts));
+    await this.writeIfChanged(paths.rss, buildRssXml(tenant, posts));
Relevance

●● Moderate

Atomic publish is a bigger refactor; unclear if team enforces atomic fs writes for nginx-served
files.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
writeIfChanged uses direct fs.writeFile on the final pathname, and nginx serves those exact
sitemap/RSS paths without reader synchronization.

apps/self-hosted/hosting/api/src/services/config-service.ts[131-145]
apps/self-hosted/hosting/api/src/services/config-service.ts[238-247]
apps/self-hosted/hosting/nginx-multi-tenant.conf[123-134]
apps/self-hosted/hosting/nginx-multi-tenant.conf[212-220]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Directly overwriting live SEO files creates a race where nginx can serve incomplete XML during regeneration.

## Issue Context
Write each changed document to a unique temporary file in CONFIG_DIR and atomically rename it over the destination on the same filesystem. Ensure temporary files are cleaned up after failures.

## Fix Focus Areas
- apps/self-hosted/hosting/api/src/services/config-service.ts[131-145]
- apps/self-hosted/hosting/api/src/services/config-service.ts[238-247]
- apps/self-hosted/hosting/nginx-multi-tenant.conf[123-134]
- apps/self-hosted/hosting/nginx-multi-tenant.conf[212-220]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational

9. SEO regen retries persistently failing RPC every pass 🐞 Bug ☼ Reliability
Description
writeSeoFilesIfStale calls fetchTenantPosts unguarded before any file write; if the bounded RPC call
rejects (timeout or chain error, e.g. a community whose feed endpoint errors), the exception
propagates and the sync pass logs a generic failure indistinguishable from a config-write failure,
with no backoff, so the tenant is retried with the same outcome every sync interval and its SEO
files remain stale indefinitely past the 30-minute freshness window.
Code

apps/self-hosted/hosting/api/src/services/config-service.ts[R236-242]

+    // One bounded chain page; a failure here is caught by the sync pass's
+    // per-tenant isolation and yesterday's files keep serving.
+    const posts = await fetchTenantPosts(tenant);
+    await fs.mkdir(CONFIG_DIR, { recursive: true });
+    await this.writeIfChanged(paths.robots, buildRobotsTxt(tenant));
+    await this.writeIfChanged(paths.sitemap, buildSitemapXml(tenant, posts));
+    await this.writeIfChanged(paths.rss, buildRssXml(tenant, posts));
Relevance

●● Moderate

Reliability concern, but adding backoff/metrics/error handling is architectural; no close prior
pattern found.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
writeSeoFilesIfStale calls await fetchTenantPosts(tenant) before any file write; if this throws,
the whole call (and the per-tenant sync iteration in syncAllConfigs) throws, is caught by
syncAllConfigs's generic per-tenant try/catch (config-service.ts ~372-378) and counted the same as a
config write failure, but the stale SEO files remain un-touched so the same tenant is retried every
sync interval with the same outcome and only a generic error log, no distinct metric/alert or
backoff.
Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A tenant whose feed RPC (bridge.get_account_posts / bridge.get_ranked_posts) persistently fails will have its SEO files regenerate-attempted on every sync pass, each attempt costing an RPC round trip and logging a generic failure indistinguishable from a config-write failure, with no distinct metric/alert for SEO-specific failures and no backoff to reduce load on a failing/slow RPC node.

## Issue Context
- `writeSeoFilesIfStale` is called from `syncAllConfigs` inside the tenant's write lock right after `writeConfigFile`.
- A failure in `fetchTenantPosts` bubbles up and is caught generically, incrementing the same `failed` counter as a config write failure.

## Fix Focus Areas
- apps/self-hosted/hosting/api/src/services/config-service.ts[217-248]
- apps/self-hosted/hosting/api/src/services/config-service.ts[366-371]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


10. Srcset sizes hardcoded, mismatched to layout grid ✓ Resolved 🐞 Bug ≡ Correctness
Description
The blog-post-item.tsx grid card's sizes attribute ("(max-width: 640px) 100vw, (max-width: 1024px)
50vw, 33vw") assumes a fixed 2/3-column responsive grid, but the actual grid column count is driven
by the theme/layout configuration and is not verified to match these breakpoints, risking the
browser choosing a smaller srcset candidate than the rendered box actually needs (blurry images) or
a larger one than needed (wasted bandwidth).
Code

apps/self-hosted/src/features/blog/components/blog-post-item.tsx[R152-154]

              src={imageUrl}
+              srcSet={imageSrcSet}
+              sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
Relevance

●● Moderate

Concern is valid but requires theme/config-aware sizing; likely seen as over-engineering vs current
responsive-media scope.

PR-#1447
PR-#1209

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The sizes string is a literal constant not derived from listType or any grid-column config,
while listType (grid vs list) is read from InstanceConfigManager and can vary per tenant/theme;
if the actual number of grid columns at a breakpoint differs from the assumed 2-column/3-column
split, buildSrcSet's fixed width candidates (320/600/800/1024/1280) combined with a wrong sizes
hint cause the browser's responsive-image selection to be sub-optimal.
Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The `sizes` attribute added alongside `srcSet` in the grid card assumes a specific column layout (100vw / 50vw / 33vw at specific breakpoints) that is not tied to the actual CSS grid/column configuration driving the card's rendered width. If the real layout differs, the browser may pick a wrongly-sized srcset candidate.

## Issue Context
- `buildSrcSet` produces fixed-width candidates (320, 600, 800, 1024, 1280px).
- The `sizes` attribute must match the actual rendered box width at each breakpoint for the browser's selection algorithm to pick optimally.
- The card grid classes are applied elsewhere (feed/grid layout component) and are not shown to align with 33vw increments.

## Fix Focus Areas
- apps/self-hosted/src/features/blog/components/blog-post-item.tsx[148-161]
- apps/self-hosted/src/features/blog/components/blog-post-item.tsx[261-282]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


11. New any types added 📘 Rule violation ⚙ Maintainability
Description
New/modified TypeScript code introduces any types (e.g., (p: any) and mock params (t: any)),
which violates the no-any requirement. This weakens type safety and can hide runtime-shape bugs in
the new SEO/metadata logic.
Code

apps/self-hosted/hosting/api/src/services/seo-files.ts[R115-117]

+  return raw.filter(
+    (p: any): p is TenantPost =>
+      typeof p?.author === 'string' && typeof p?.permlink === 'string',
Relevance

● Weak

Similar “remove any” suggestion was previously rejected; team may tolerate any for runtime shape
filtering/mocks.

PR-#1459

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The compliance rule disallows introducing any in changed TypeScript. The new seo-files.ts uses
(p: any) in the raw.filter predicate, and updated/new tests introduce (t: any) for mocked
TenantService.getBlogUrl implementations.

Rule 2668119: Disallow implicit and any types in new TypeScript code
apps/self-hosted/hosting/api/src/services/seo-files.ts[115-118]
apps/self-hosted/hosting/api/src/services/config-publish-lock.test.ts[18-22]
apps/self-hosted/hosting/api/src/services/seo-files.test.ts[8-14]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The PR introduces new `any` usage in TypeScript files, which is disallowed by policy.

## Issue Context
The new SEO helpers and updated tests add `(p: any)` and `(t: any)` patterns. These can be replaced with `unknown` plus narrowing, or with small structural types (or `Tenant`) where appropriate.

## Fix Focus Areas
- apps/self-hosted/hosting/api/src/services/seo-files.ts[115-118]
- apps/self-hosted/hosting/api/src/services/config-publish-lock.test.ts[18-22]
- apps/self-hosted/hosting/api/src/services/seo-files.test.ts[8-14]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Context
✅ Compliance rules (platform): 75 rules
✅ Skills: 6 invoked
  add-feature
  add-query
  add-sdk-mutation
  add-test
  code-review
  debug
✅ Web pages:
  +12 more
Review mode: 🧠 Deep: This spans hosting sync/locking, nginx routing, canonical SEO policy, RSS/sitemap generation, SSR metadata, dependency loading, and responsive media across independent code paths, creating a high density of subtle defects that benefits from redundant review.

Grey Divider

Tip of the day
💡 Did you know, you can enable the Remediation agent and Qodo fixes findings in a dedicated fix PR

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread apps/self-hosted/hosting/api/package.json
Comment thread apps/self-hosted/hosting/api/src/services/seo-files.test.ts
Comment thread apps/self-hosted/hosting/api/src/services/seo-files.ts Outdated
Comment thread apps/self-hosted/hosting/api/src/services/config-service.ts Outdated
Comment thread apps/self-hosted/hosting/api/src/services/config-service.ts
Comment thread apps/self-hosted/hosting/api/src/services/seo-files.ts
Comment thread apps/self-hosted/hosting/api/src/services/config-service.ts
Comment thread apps/self-hosted/hosting/api/src/services/post-meta.ts Outdated
Comment thread apps/self-hosted/src/features/blog/components/blog-post-item.tsx Outdated
Comment thread apps/self-hosted/hosting/api/src/services/config-service.ts
feruzm added 2 commits August 12, 2026 19:22
…dent path

The SEO refresh runs on its own five-minute loop with its own single
flight and a small worker pool, since spending the chain timeout inside
the serial config pass let an RPC outage delay config publication and
cleanup by tenant count times ten seconds. The chain call is aborted at
its deadline (the SDK timeout plus an AbortController), a malformed
answer throws so yesterday's files are kept rather than blanked and
marked fresh, every record field the builders touch is type-checked and
mtime freshness is fingerprinted by the RPC-free robots content so a
custom-domain change regenerates immediately instead of advertising the
old address for the window. escapeHtml removes XML 1.0-invalid code
points and lone surrogates before escaping, so one chain-authored control
character can no longer unparse a tenant's whole feed.

The independent path ships: a generate-seo CLI in the hosting-api image
runs the same builders from an instance's config.json (its own URL acts
as a verified custom domain, so the canonical policy lands on self),
DEPLOYMENT.md documents the cron and compose mounts and a
general.rssFeedUrl config override points the app's RSS link at the
owner's own feed, with junk overrides ignored.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/self-hosted/hosting/api/scripts/generate-seo.ts`:
- Around line 45-67: Replace the regex URL validation with a single URL parse
near the initial argument checks, and validate that the URL uses HTTPS, has no
username or password, query, or fragment, and has pathname “/” so trailing-root
URLs remain valid. Reuse the parsed URL when deriving host in the existing host
initialization instead of constructing a second URL instance, while preserving
the current usage error and exit behavior for invalid input.

In `@apps/self-hosted/hosting/api/src/services/post-meta.ts`:
- Around line 69-73: Update proxyBaseOf in
apps/self-hosted/hosting/api/src/services/post-meta.ts (lines 69-73) to parse
imageProxy with URL, accepting only HTTP(S) URLs with a hostname before removing
trailing slashes; otherwise return null. Apply the same URL parsing and
protocol/hostname validation to rssFeedUrl in
apps/self-hosted/src/utils/rss-feed-url.ts (lines 16-17). Add malformed HTTP(S)
cases in apps/self-hosted/hosting/api/src/services/post-meta.test.ts (lines
114-132) and verify metadata falls back to the default proxy.

In `@apps/self-hosted/hosting/api/src/utils/escape-html.ts`:
- Around line 9-13: Update INVALID_XML_CHARS in
apps/self-hosted/hosting/api/src/utils/escape-html.ts:9-13 to remove the
C1-control ranges and U+FDD0-U+FDEF, preserving those XML 1.0 Fifth Edition
characters while retaining genuinely invalid ranges. In
apps/self-hosted/hosting/api/src/utils/escape-html.test.ts:11-16, update the
test description and add coverage confirming these characters are preserved.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: a63eaccd-3c15-4b8d-b7c6-f656ba0d4ed8

📥 Commits

Reviewing files that changed from the base of the PR and between 620b839 and b49d9d1.

📒 Files selected for processing (19)
  • apps/self-hosted/DEPLOYMENT.md
  • apps/self-hosted/hosting/api/Dockerfile
  • apps/self-hosted/hosting/api/package.json
  • apps/self-hosted/hosting/api/scripts/generate-seo.ts
  • apps/self-hosted/hosting/api/src/index.ts
  • apps/self-hosted/hosting/api/src/services/config-service.test.ts
  • apps/self-hosted/hosting/api/src/services/config-service.ts
  • apps/self-hosted/hosting/api/src/services/hivesigner-registry.test.ts
  • apps/self-hosted/hosting/api/src/services/post-meta.test.ts
  • apps/self-hosted/hosting/api/src/services/post-meta.ts
  • apps/self-hosted/hosting/api/src/services/seo-files.test.ts
  • apps/self-hosted/hosting/api/src/services/seo-files.ts
  • apps/self-hosted/hosting/api/src/utils/escape-html.test.ts
  • apps/self-hosted/hosting/api/src/utils/escape-html.ts
  • apps/self-hosted/src/core/configuration-loader.ts
  • apps/self-hosted/src/features/blog/layout/blog-navigation.tsx
  • apps/self-hosted/src/index.tsx
  • apps/self-hosted/src/utils/rss-feed-url.test.ts
  • apps/self-hosted/src/utils/rss-feed-url.ts
🚧 Files skipped from review as they are similar to previous changes (5)
  • apps/self-hosted/src/index.tsx
  • apps/self-hosted/hosting/api/package.json
  • apps/self-hosted/src/features/blog/layout/blog-navigation.tsx
  • apps/self-hosted/src/utils/rss-feed-url.test.ts
  • apps/self-hosted/hosting/api/src/services/seo-files.ts

Comment thread apps/self-hosted/hosting/api/scripts/generate-seo.ts Outdated
Comment thread apps/self-hosted/hosting/api/src/services/post-meta.ts Outdated
Comment thread apps/self-hosted/hosting/api/src/utils/escape-html.ts
@feruzm
feruzm merged commit ac13ea3 into develop Aug 12, 2026
12 checks passed
@feruzm
feruzm deleted the feature/self-hosted-seo-media branch August 12, 2026 21:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Research responsive image and social-preview handling for self-hosted posts Research static SEO, RSS, and sitemap support for self-hosted instances

1 participant