Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions lib/page-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,9 +243,9 @@ export function buildRegistryMetadata(state: PagePublicationState): Metadata {
type: "website",
url: canonicalPath,
},
robots: {
robots: state.index ? undefined : {
follow: true,
index: state.index,
index: false,
},
title: state.page.meta_title,
other: { "stack127-content-revision": new Date(state.page.row_updated_at).toISOString() },
Expand Down
2 changes: 1 addition & 1 deletion scripts/stack127/publishing/verify.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export function inspectIndexingHtml(html, { slug, is_indexable = true }, site) {
const canonical = [...html.matchAll(/<link\b[^>]*>/gi)].map(match => attributes(match[0])).find(item => item.rel === 'canonical')?.href;
const expectedCanonical = new URL(slug ? `/${slug}` : '/', site).href;
const indexingCorrect = is_indexable
? robots.includes('index') && !robots.includes('noindex') && !robots.includes('none')
? !robots.includes('noindex') && !robots.includes('none')
: robotsTag.includes('noindex') && !robots.includes('index');
// URL parsing treats https://stack127.com and https://stack127.com/ as the same root.
let canonicalHref = null;
Expand Down
2 changes: 1 addition & 1 deletion stack127_docs/cloudflare.md
Original file line number Diff line number Diff line change
Expand Up @@ -336,7 +336,7 @@ Before upload, `assets.mjs` checks the Cloudflare Free limits: at most 20,000 fi
| `/404` | `no-store` and `noindex` |
| `/frontend-release.json` | `noindex` |

`proxy.ts` doesn't run on static hosting. A free zone response header transform rule, `Stack127 query pages: noindex`, sets `X-Robots-Tag: noindex, follow` on any query URL outside `/api/`, `/_next/`, and `/vitals/`. Canonicals, robots meta tags, and JSON-LD still come from the registry, inside the exported HTML.
`proxy.ts` doesn't run on static hosting. A free zone response header transform rule, `Stack127 query pages: noindex`, sets `X-Robots-Tag: noindex, follow` on any query URL outside `/api/`, `/_next/`, and `/vitals/`. Canonicals, robots meta tags, and JSON-LD still come from the registry, inside the exported HTML. Indexable pages send no robots meta tag, so a query URL, which gets the same file, carries only the `noindex` header.

Static files are public whatever cookies the request carries, so analytics cookies don't lower the hit rate. The JSON routes on OVH keep their cookie bypass. To cache JSON requests that carry analytics cookies, add a strict allowlist of those cookie names. Don't remove the cookie checks.

Expand Down
2 changes: 1 addition & 1 deletion stack127_docs/page-publishing.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ Readiness checks only read. Import a directory's records with its own reviewed c
`apply` and `restore` always check the public pages after they commit. `verify` runs the same check on its own. The check reads the database first, then loads each page until it matches or the wait runs out. The wait is 180 seconds by default.

- A published page must return 200. Its title, `h1`, description, canonical, and robots tags must match the plan. Its `stack127-content-revision` tag must match the registry row. A tool with a repository URL must show that link.
- An indexable published page must be in the sitemap and send index metadata. A published noindex page must send a noindex robots tag, keep its own canonical, send no JSON-LD, and be absent from the sitemap.
- An indexable published page must be in the sitemap, and no robots tag may say `noindex` or `none`. A published noindex page must send a noindex robots tag, keep its own canonical, send no JSON-LD, and be absent from the sitemap.
- Canonical and sitemap URLs use the site's configured origin, the same one `lib/seo` uses. That's `https://stack127.com` in production, and `NEXT_PUBLIC_SITE_URL` from the environment or `.env.local` in development. It falls back to `https://stack127.com` when unset. `--site` doesn't change it.
- Each smoke path must show its expected text.
- An unpublished page must return 404 and be missing from the sitemap.
Expand Down
6 changes: 3 additions & 3 deletions stack127_docs/seo.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ Follow these route rules:

| request | behavior |
| --- | --- |
| Exact published slug with `is_indexable = true` | Render, `index, follow`, sitemap included, JSON-LD included |
| Exact published slug with `is_indexable = true` | Render, no robots meta tag (indexing is the default), sitemap included, JSON-LD included |
| Exact published slug with `is_indexable = false` | Render, `noindex, follow`, sitemap excluded, no JSON-LD, canonical points to itself |
| Exact unpublished slug, whatever `is_indexable` says | `404`, sitemap excluded |
| Exact published slug with query params, for example `/self-host-apps?page=2` | Render the same static page, `noindex, follow` through the proxy header (or the Cloudflare header rule on the static frontend), sitemap excluded |
Expand All @@ -63,7 +63,7 @@ Use `is_indexable = false` for thin child pages, filtered views with their own p
Cloudflare hosting mode uses the SEO behavior below. See [cloudflare.md](cloudflare.md#static-frontend-on-cloudflare) for its activation and release checks.

- The export holds all published pages, including noindex pages. Anything unpublished returns the static 404 with `noindex`.
- Titles, descriptions, canonicals, robots meta tags, and JSON-LD come from the registry, written into the exported HTML at build time.
- Titles, descriptions, canonicals, robots meta tags, and JSON-LD come from the registry, written into the exported HTML at build time. Indexable pages have no robots meta tag. A query URL shares its page's HTML file, so an `index` tag would contradict the `noindex` header it gets.
- `proxy.ts` doesn't run. A free Cloudflare response header transform rule sends `X-Robots-Tag: noindex, follow` on query URLs. It doesn't add the proxy's canonical `Link` header. The HTML canonical covers that.
- RSC `.txt` files, `/frontend-release.json`, and the `workers.dev` preview send `noindex`.
- A registry change reaches the public HTML only after the frontend workflow rebuilds and deploys the export.
Expand Down Expand Up @@ -98,5 +98,5 @@ Do not block useful pages in `robots.txt` just to handle duplicates. Prefer `noi
2. Keep page code free of metadata, robots, canonical, sitemap, and JSON-LD exports.
3. Add or update the page docs under `content_docs/<page-slug>/`.
4. When the page should become public, insert a reviewed row into `public.stack127_pages` with the correct `page_family`.
5. Check the indexing setting. Indexable pages send `index, follow` and enter `/sitemap.xml`. Noindex pages render with their own canonical, send `noindex, follow`, omit JSON-LD, and stay out of the sitemap. Child/query variants stay `noindex, follow`.
5. Check the indexing setting. Indexable pages send no robots meta tag and enter `/sitemap.xml`. Noindex pages render with their own canonical, send `noindex, follow`, omit JSON-LD, and stay out of the sitemap. Child/query variants stay `noindex, follow`.
6. Follow [caching.md](caching.md). The page must be static and use on-demand revalidation. Its data source must be covered by the revalidation pipeline.
16 changes: 16 additions & 0 deletions tests/stack127/page-publishing.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ test('a healthy site with another build cannot satisfy the publication gate', as

test('public verification checks metadata, visible heading and the current cache revision', () => {
inspectPublishedHtml(html(), state, revision, 'https://stack127.com');
inspectPublishedHtml(html().replace('<meta name="robots" content="index, follow">', ''), state, revision, 'https://stack127.com');
assert.throws(() => inspectPublishedHtml(html().replace(revision, '2026-09-01T00:00:00.000Z'), state, revision, 'https://stack127.com'), /older revision/);
assert.throws(() => inspectPublishedHtml(html().replace('index, follow', 'noindex, follow'), state, revision, 'https://stack127.com'), /indexing/);
assert.throws(() => inspectPublishedHtml(html().replace('Find <mark>examples</mark>', 'Old heading'), state, revision, 'https://stack127.com'), /stale/);
Expand Down Expand Up @@ -247,6 +248,21 @@ test('older directory plans must be regenerated when either snapshot omits a car
assert.equal(validatePlan(missingContent, 'directories', 'production').schema_version, 1);
});

test('indexable pages allow absent robots metadata and reject blocking directives from either tag', () => {
const site = 'https://stack127.com';
const page = { slug: 'example', is_indexable: true };
const canonical = '<link rel="canonical" href="https://stack127.com/example">';
assert.doesNotThrow(() => inspectIndexingHtml(canonical, page, site));
for (const name of ['robots', 'googlebot']) {
assert.doesNotThrow(() => inspectIndexingHtml(`${canonical}<meta name="${name}" content="index, follow">`, page, site));
for (const directive of ['noindex, follow', 'none']) {
const blocked = `${canonical}<meta name="${name}" content="${directive}">`;
assert.throws(() => inspectIndexingHtml(blocked, page, site), /indexing metadata/);
assert.throws(() => inspectIndexingHtml(`${blocked}<meta name="robots" content="index, follow">`, page, site), /indexing metadata/);
}
}
});

test('the root canonical passes with or without a trailing slash, and wrong paths still fail', () => {
const page = canonical => `<link rel="canonical" href="${canonical}"><meta name="robots" content="index, follow">`;
const site = 'https://stack127.com';
Expand Down
Loading