Skip to content

Custom page rejections and fixes

What this is

The complete catalog of rejections and failures for ComStack custom pages: every error upload-custom-page can return, every publish-stage failure that affects custom pages, and the advisory lint findings. Each entry gives the exact trigger, the real message text, and the fix — written so a developer or an agent can self-correct from the message alone. The happy path is documented in Author a custom page.

How it works

Custom-page failures surface at two stages, plus one channel that never blocks:

  1. Upload time. upload-custom-page validates synchronously and rejects the whole call with a parameter error. Nothing is stored on rejection; an audit row records the attempt with an error code such as marker_extraction_failed or blocked_href_scheme.
  2. Publish time. publish (the dry run) refuses to mint a confirmation token for ambiguous draft states. publish-confirm runs asynchronously, so later failures — the Lighthouse gate, the site build — surface when you poll publish-status as status: "failed" with the error text.
  3. Lint — never. Lint findings come back in the upload response as lint_findings with severity: "warning". They are advisory only and never block an upload or a publish. They earn their place because they predict Lighthouse Performance failures, which do block when the gate runs.

Messages below are quoted as the implementation emits them, with example values standing in for the variable parts.

When to use it

  • upload-custom-page returned an error and you want the precise fix, not a guess.
  • publish refused to produce a confirmation token, or publish-status reports failed.
  • A draft you expected to publish is missing from the publish manifest.
  • You are deciding whether lint_findings need fixing before you publish.

Upload-time rejections

Title and description bounds

Trigger: title shorter than 10 or longer than 60 characters; description shorter than 50 or longer than 160.

title must be between 10 and 60 characters
description must be between 50 and 160 characters

Fix: count characters, spaces included, and rewrite within bounds. On multilingual sites, machine-translated titles and descriptions that run long are hard-cropped to 60/160 characters with a trailing — leave headroom if you can.

project_name mismatch

Trigger: project_name does not match the project’s actual name (the comparison is case-insensitive, after trimming whitespace).

project_name does not match project k2xPq9wEfGhRtY3LmN8b: expected 'Acme Robotics', got 'Acme'

Fix: copy the name verbatim from get-project-state. The echo-back is a safeguard so a destructive call can never run against the wrong project.

language not in the project’s locales

Trigger: language names a locale the project is not configured for.

language 'fr' is not in project.locales [en, es]. Add it to modules.custom_pages.locales first, or omit `language` to use the default_locale ('en').

Fix: the message is self-correcting — add the locale to the custom-pages module, or drop the language argument. Remember that language is the page’s source locale, not a translation request (publish translates automatically), and that it is ignored on updates: an existing page keeps its language.

Slug validation

Trigger: any /-separated slug segment that is not lowercase kebab-case, or a bare /.

Slug must be lowercase kebab-case. Use lowercase letters, digits, and hyphens. Use '/' to nest path segments. Use 'index' for the locale root.

Fix: valid examples: index, guides/buying, blog/buying-property-spain-2026. The locale root is the literal slug index, never /.

A second, rarer slug rejection — when the project’s live agent is placed on a destination page, that one slug is reserved:

Slug 'talk' is reserved by settings/site.live_agent_slug while live_agent_placement is 'destination_page'.

Fix: as the error’s fix text says — pick a different slug, or move the live-agent placement away from destination_page to release it.

Marker extraction failures

Trigger: any invalid translation marker in body_source. The upload is rejected wholesale, and every problem is listed at once:

Marker extraction failed (2 errors):
1. offset=118 line=4 col=7 key='Hero.Title' — Invalid marker key 'Hero.Title'. Keys must match /^[a-z][a-z0-9_.]*[a-z0-9]$/ (lowercase, digits, underscore, dot).
2. offset=240 line=7 col=5 — <T> marker is missing required `key` attribute.

The individual messages:

TriggerExact message
Key fails the pattern — uppercase, leading digit, trailing . or _, or a single character (two is the minimum)Invalid marker key 'Hero.Title'. Keys must match /^[a-z][a-z0-9_.]*[a-z0-9]$/ (lowercase, digits, underscore, dot).
<T> without a key attribute<T> marker is missing required `key` attribute.
key={expr} instead of a string literal<T key={...}> dynamic key expressions are not allowed. Use a static string literal.
default={expr} instead of a string literal<T default={...}> dynamic defaults are not allowed. Use a static string literal.
No default attribute and no inner text<T key='hero.cta'> is missing a default. Provide a static `default` attribute or static inner text.
{expression} inside a <T> element<T>{expression}</T> dynamic inner content is not allowed. Use a static default attribute or static text.
A fragment inside a <T> element<T> markers may not contain fragments. Use a static default attribute or static text.
<T> nested inside <T>Nested <T> markers are not allowed. A marker cannot contain another marker.
Body routes to the JSX parser but is not valid JSXJSX/Astro parse error: Unexpected token (8:20). The body_source must be parseable JSX/Astro embedded inside a fragment.
data-t-key element containing child elementsMarker 'hero.title' contains nested HTML elements. Marker elements must wrap plain text only.
data-t-key element with empty textMarker 'hero.title' has empty text content. The element's text becomes the English default; it must be non-empty.
data-t-key nested inside another data-t-key elementNested data-t-key markers are not allowed. 'inner.key' is inside marker 'outer.key'.
Same key used twice with different defaults (both forms)Duplicate marker key 'hero.cta' with conflicting default. Existing default: 'Talk to us'. New default: 'Contact us'. Use one default per key.

Fixes:

  • Keys: start with a lowercase letter, end with a letter or digit, dots and underscores only in between, minimum two characters. hero.title passes; Hero, 1cta, nav., and x are all rejected.
  • The JSX parse error usually means an inline <style> or <script> block on the Astro/JSX path — their { braces are not valid JSX. Routing to that parser is a plain text scan, not parse-aware: a body that starts with --- frontmatter, or that contains the sequence <T followed by whitespace, /, or > anywhere — even inside a JavaScript string or an HTML comment — is parsed as JSX. So an HTML body whose inline script contains a literal <T trips this error too; remove the sequence or split it ('<' + 'T '). Otherwise: author the page in the HTML form (data-t-key markers), or move styles and scripts out of the body. The (line:col) in the message points at the first offending character.
  • A key may repeat only with the identical default. Deduplicate, or rename one of the keys.
  • Markers wrap plain text only — restructure nested markup into several sibling markers.
  • The “English default” wording is literal in the message; strictly it is the source-locale default (whatever your language resolves to).

Blocked href schemes

Trigger: any href or xlink:href attribute whose value resolves to javascript:, data:, or vbscript:. Values are normalized before the check — HTML entities decoded, embedded whitespace and control characters stripped — so obfuscated forms like java&#x73;cript: or a tab inside the scheme are caught. src attributes are not checked: inline data: images keep working. The attempt is recorded in the audit trail with error code blocked_href_scheme.

body_source contains 1 blocked href scheme — javascript:, data: and vbscript: hrefs are rejected (XSS hard boundary; the body ships to the browser as authored):
1. offset=412 scheme=javascript: href="javascript:alert(1)"
Use https:, mailto:, tel:, relative or #anchor URLs instead. See transformento://docs/custom-pages/authoring.

Fix: as the message says. For click behavior, attach a listener from a deferred script instead of a javascript: URL.

Wrong tool for the page type

Trigger: the slug resolves to an existing knowledge (markdown) page. The rejected attempt is recorded in the audit trail with error code wrong_tool_knowledge_page.

'pricing' (slug 'pricing') is a KNOWLEDGE (markdown) page, not a custom page. upload-custom-page would overwrite its body with opaque body_source. Edit it with update-page, or pick a different slug for the custom page.

Fix: exactly as stated — edit that page with update-page, or choose a different slug. The guard is symmetric: update-page on a custom page rejects too, with

'solutions-voice' is a CUSTOM page (metadata.type="custom"). Edit it with upload-custom-page (body_source + <T key=…> markers), not update-page. update-page edits knowledge (markdown) pages only.

When unsure, get-page-content returns page_type"custom" or "knowledge" — so you can pick the right editor before writing. See Page types.

is_faq is forbidden

Trigger: passing is_faq: true. The tool has no such parameter, and sneaking it into the raw arguments is detected.

is_faq=true is forbidden on custom-page docs (template.forbids_is_faq=true). FAQ JSON-LD is a knowledge-page concern.

Fix: put FAQ content on a knowledge page. Custom pages never emit FAQ structured data.

Body over the 2 MB hard cap

Trigger: body_source larger than 2 MB (2,097,152 bytes of UTF-8).

body_source is 2483911 bytes — above the 2 MB hard cap. Move large inline assets out of body_source (Cloudinary / static).

Fix: host images, video, and fonts with upload-media (or any CDN) and reference them by URL. The body-too-large lint warning fires earlier, at 500 KB — treat it as the canary.

media_variants validation

Trigger: a malformed media_variants map. Four distinct rejections:

  • Wrong overall shape:
media_variants must be an object map of { "<key>": { "<locale>": "<absolute URL>" } }.
  • A key without a matching marker in the body:
media_variants key 'hero.shot' has no matching data-t-media marker in the page body. Add data-t-media="hero.shot" to the <img|video|source> element, or remove the override.
  • A locale map that is not an object:
media_variants['hero.shot'] must be a { "<locale>": "<absolute URL>" } object.
  • A relative or non-http URL:
media_variants['hero.shot']['es'] must be an absolute http(s) URL (got /img/hero-es.png).

Fix: every key in media_variants must match a data-t-media attribute in the body, and every URL must be absolute http(s). The map is optional — locales without an entry render the element’s inline src.

Other metadata validation

  • og_image must be an absolute http(s) URL.
  • published_date and updated_date must be ISO 8601 dates — YYYY-MM-DD, optional time suffix.
  • schema_org_type must be one of WebPage, Article, AboutPage, ContactPage, CollectionPage, ProfilePage.

These come back as field-named validation issues with the allowed values listed; fix the named field and retry.

Publish-stage failures

duplicate_draft_target

Trigger: at publish (dry run), two or more pending drafts target the same (slug, language) URL — typically a leftover draft plus a newer one. No confirmation token is minted.

duplicate_draft_target: 1 (slug, language) target(s) have more than one pending draft. No confirmation token was minted. 'solutions/voice' (en) → solutions-voice, k9XbQ2mEphd71GfTzALw. Discard the extra drafts (discard-draft) or change a slug, then publish again.

The structured error data lists each collision and marks exactly one draft newest: true.

Fix: keep the draft marked newest and discard-draft the others — or change a slug if both pages should exist — then run publish again.

A draft missing from the manifest: zero-diff

Trigger: not an error. A draft whose content is identical to the live page is excluded from the publish manifest and deleted, not promoted at publish time. Editing a live page with byte-identical content does not even create a draft — the upload response reports "action": "no_change".

Fix: nothing to fix. If you expected changes, diff what you uploaded against the live page; you probably re-uploaded the current version.

Confirmation token expired

Trigger: more than 5 minutes between publish and publish-confirm.

Confirmation token has expired (5-minute TTL). Call publish again to get a fresh token, then call publish-confirm immediately.

Fix: as stated. Tokens are also single-use and bound to the user, the project, and the exact manifest — if the draft set changed after the dry run, the token is invalidated and you must publish again.

slug_in_use on re-upload of a published page

Trigger: a known rough edge in the upsert. upload-custom-page finds an existing page by the document id it derives from the slug. The first upload creates the page at that id and the first publish keeps it — uploads keep working. But the first time you edit and republish a live custom page, its document id rotates; the next upload for that slug finds nothing at the derived id, takes the create path, and trips the slug-collision guard against the rotated live page:

Slug 'solutions/voice' (language 'en') is already used by another page (path: 'k9XbQ2mEphd71GfTzALw', visibility: live). Two pages with the same slug + language would deploy to the same URL.

The error’s suggested fix — update-page — does not work for custom pages; it rejects them (see “Wrong tool for the page type” above).

Fix, today: call delete-page with the path named in the error (the rotated live document), re-run the same upload-custom-page call, then publish. The deployed site keeps serving the existing page until the publish completes, so visitors see no gap — but re-upload immediately after the delete, since the deletion removes the stored page at once. In short: expect every upload to work until a page has been republished once; the upload after that republish hits this error until the platform closes the edge.

Lighthouse gate failure — and force

Trigger: custom pages are audited at pre-publish against a bar of 95 in all four categories — Performance, Accessibility, Best Practices, SEO — when the platform’s quality gate runs. Any category below 95 on any audited custom page fails the publish: publish-status reports status: "failed" with

Lighthouse pre-publish gate failed: https://www.acme-robotics.com/solutions/voice scored below 95 (perf=82, a11y=98, bp=100, seo=100)

Multiple failing URLs are joined with |. Each failing URL also carries a remediation list — the specific failing audits as auditId: title — description — naming exactly what to fix.

Fix: build to ≥95; that is the contract. The upload-time lint rules map to the most common Performance failures — eager scripts, non-lazy images, render-blocking stylesheets, fonts without font-display: swap, unoptimized images. Fix, re-upload, publish again.

force: publish-confirm accepts force: true, which lets a below-bar score through. It is never silent: the findings record Lighthouse gate forced past failing scores via force:true. Audit warning will be recorded., the publish record is stamped as forced, and an audit entry is written. Use it for genuine emergencies, then fix the page.

Build-time substitution failure

Trigger: rare — a stored body that no longer parses when translations are spliced in at site build (in practice only possible if content was modified outside the tool). The build fails loudly and the publish fails with it:

custom-page substitute: body_source did not parse as JSX/Astro. Re-extract markers and re-upload.

Fix: re-run upload-custom-page with the full body — upload-time extraction will list the actual parse problems — then publish again.

Lint findings — warnings, never blockers

Returned in the upload response as lint_findings, each shaped { rule, severity, line, column, message, fix_hint }. Every finding is severity: "warning"; nothing here blocks an upload or a publish. Each rule predicts a Lighthouse Performance failure, which does block when the gate runs — so fix them anyway.

RuleTriggerFix
body-too-largebody_source over 500 KB (soft cap; the hard rejection is at 2 MB)Move inline assets out to upload-media / CDN URLs
no-eager-script<script> without defer or async (JSON-LD, templates, and type="module" are exempt)Add defer (preferred) or async
img-not-lazy<img> without loading="lazy" — suppressed when data-hero appears within the preceding 4 KB of markupAdd loading="lazy", or mark the hero/LCP image’s container with data-hero
blocking-stylesheet<link rel="stylesheet"> with no media attribute and not preloadedUse media="print" onload="this.media='all'", or a preload-then-apply pattern
no-font-display-swap@font-face block without font-display: swapAdd font-display: swap; to the block
cloudinary-not-autoA Cloudinary URL missing f_auto and/or q_auto (the message names whichever is missing)Insert f_auto,q_auto/ after /upload/ — the platform never rewrites your URLs

Example

A self-correcting round trip. First attempt:

{
"project_id": "k2xPq9wEfGhRtY3LmN8b",
"project_name": "Acme Robotics",
"slug": "about",
"title": "About Acme Robotics",
"description": "Who builds Acme's multilingual voice agents — the team, the mission, and how to reach us.",
"body_source": "<section>\n <h1 data-t-key=\"Hero\">We teach robots to listen</h1>\n</section>"
}

Rejected — the key starts with an uppercase letter:

Marker extraction failed (1 error):
1. offset=12 key='Hero' — Invalid marker key 'Hero'. Keys must match /^[a-z][a-z0-9_.]*[a-z0-9]$/ (lowercase, digits, underscore, dot).

Change data-t-key="Hero" to data-t-key="about.hero" and re-upload the full body:

{
"ok": true,
"page": { "success": true, "path": "about", "visibility": "draft", "draft_url": "<edit-mode preview link>", "next_step": "<review the draft, then publish>" },
"markers_extracted_count": 1,
"lint_findings": [],
"docs": {
"authoring_guide": "transformento://docs/custom-pages/authoring",
"troubleshooting": "transformento://docs/custom-pages/troubleshooting"
}
}

Common errors

The five most frequent, one line each:

  1. title must be between 10 and 60 characters — count characters, spaces included; same idea for the 50–160 description.
  2. language 'fr' is not in project.locales [en, es]. … — add the locale to the custom-pages module, or omit language.
  3. Invalid marker key '…' — lowercase start, alphanumeric end, dots and underscores inside, minimum two characters.
  4. body_source contains 1 blocked href scheme … — no javascript:/data:/vbscript: hrefs; use https:, mailto:, tel:, relative, or #anchor.
  5. Confirmation token has expired (5-minute TTL). … — run publish again and confirm immediately.

Last updated: