Skip to content

Commit d64aa6c

Browse files
authored
SEO cleanup: sitemap, 404 metadata, coming-soon heading (#133)
* SEO cleanup: sitemap, 404 metadata, coming-soon heading Three small fixes found while validating the live site. Sitemap advertised /_not-found/. collectPages() emits a URL for any directory holding an index.html and filters only top-level names in an explicit deny-list. With trailingSlash the export writes out/_not-found/index.html, and _not-found was not in the list. #123 fixed exactly this class for 404 but only added that one name. The page also serves noindex, so the sitemap was submitting a URL that tells crawlers not to index it. Added _not-found to the exclusion set. 404 page emitted two conflicting robots tags. not-found.tsx exported no metadata, so it inherited the root layout's, which hardcodes index/follow in metadataService - and Next.js separately injects noindex for the not-found route. It also carried the homepage title byte for byte. Added a metadata export with a distinct title and index: false. /docs/architecture had no heading element at all - not just no h1, zero h1-h6. The coming-soon layout renders the graphic plus placeholder prose, and the markdown carries no heading, while the sidebar section label is deliberately a <p> because "the article's h1 comes from the markdown content". That contract is unmet for coming-soon pages, so the h1 now comes from the frontmatter title, guarded in case a future coming-soon page does start with a markdown heading. The docs index also showed the Architecture card with no marker, styled identically to the six finished ones. Marked it the same way the Kubernetes Operator card marks preview status, via a title suffix. Fixes #129 * Filter the sitemap on noindex rather than on directory name The exclusion list was growing one framework route at a time. #123 added 404 after it appeared in the sitemap; this branch added _not-found for the same reason; and the versioned-docs work in progress adds a third hand-written skip for the archived version directories. Each is the same rule discovered again: do not advertise a page that tells crawlers not to index it. That property is readable from the page itself, so collectPages now reads each index.html and skips the ones carrying a noindex robots meta. The name list keeps only the directories that hold no pages at all - _next, deb, rpm, images - where it is a traversal concern rather than an indexing decision. The tag is matched in either attribute order and tolerates content lists such as "noindex,nofollow", since Next.js and hand-written metadata do not agree on either. The final log line now reports how many pages were skipped, so a filter that starts matching too much is visible in the build output instead of silently shrinking the sitemap. Verified against a synthetic export carrying an indexable root, /docs, /docs/versions, /packages and /samples, plus three noindex pages written in three different tag forms and an excluded _next directory: five URLs emitted, three skipped, and out/404.html left alone as the file it is. Note that the archived-version case is now covered by this rule, so the docs/versions skip on the versioned-docs branch can go when it lands.
1 parent c29cb76 commit d64aa6c

4 files changed

Lines changed: 68 additions & 15 deletions

File tree

app/docs/[section]/[[...slug]]/page.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,12 @@ export default async function ArticlePage({ params }: PageProps) {
8383
// Use title from frontmatter if available, otherwise fall back to navigation title or section name
8484
const pageTitle = frontmatter.title || selectedNavItem?.title || section;
8585
const showInstallPrimer = section === "getting-started" && file === "index";
86+
const isComingSoon = frontmatter.layout === 'coming-soon';
87+
// coming-soon pages carry placeholder prose with no markdown heading, so
88+
// the h1 has to come from the frontmatter title - otherwise the page ships
89+
// with no heading element at all. Guarded in case a future coming-soon page
90+
// does start with one.
91+
const showComingSoonHeading = isComingSoon && !/^#\s/m.test(content);
8692
const sectionTitle = capitalCase(section)
8793
.replace(/documentdb/i, 'DocumentDB')
8894
.replace(/api/i, 'API');
@@ -184,7 +190,12 @@ export default async function ArticlePage({ params }: PageProps) {
184190
</details>
185191

186192
{/* Coming Soon Component for coming-soon layout */}
187-
{frontmatter.layout === 'coming-soon' && <ComingSoon />}
193+
{showComingSoonHeading && (
194+
<h1 className="text-4xl font-bold text-white mb-4">
195+
{pageTitle}
196+
</h1>
197+
)}
198+
{isComingSoon && <ComingSoon />}
188199

189200
{showInstallPrimer && (
190201
<section className="mb-8 rounded-2xl border border-blue-500/30 bg-gradient-to-br from-blue-500/10 via-neutral-900/90 to-neutral-900/90 p-6">

app/not-found.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,19 @@
1+
import type { Metadata } from "next";
12
import Link from "next/link";
23

4+
// Without this the route inherits the root layout's metadata, which hardcodes
5+
// robots index/follow - so the page emitted both that tag and the noindex
6+
// Next.js injects for not-found, and carried the homepage title verbatim.
7+
export const metadata: Metadata = {
8+
title: "Page not found - DocumentDB",
9+
description:
10+
"The page you're looking for doesn't exist. Find documentation, downloads, and samples for DocumentDB.",
11+
robots: {
12+
index: false,
13+
follow: true,
14+
},
15+
};
16+
317
const suggestions = [
418
{
519
title: "Documentation",

articles/content.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ landing:
1212
link: /docs/postgres-api
1313
- title: DocumentDB Local
1414
link: /docs/documentdb-local
15-
- title: Architecture under the hood
15+
- title: Architecture under the hood (Coming soon)
1616
link: /docs/architecture
1717
- title: Samples & Demos
1818
link: /samples

scripts/generate-sitemap.mjs

Lines changed: 41 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,21 +11,41 @@ import path from 'node:path';
1111
const siteUrl = 'https://documentdb.io';
1212
const outDir = path.join(process.cwd(), 'out');
1313

14-
// Top-level build outputs that are not HTML pages: Next.js assets, the APT/RPM
15-
// package repositories, images, and the not-found page (with trailingSlash the
16-
// export emits out/404/index.html alongside out/404.html). The packages
17-
// workflow adds deb/ and rpm/ after this script runs in the deploy job, but
18-
// they are excluded here too so local full builds behave identically. Note
19-
// that out/packages/ is NOT excluded: it is the exported /packages download
20-
// page; the workflow only adds release-info.json (not a page) next to it.
14+
// Top-level build outputs that are not pages at all: Next.js assets, the
15+
// APT/RPM package repositories, and images. The packages workflow adds deb/
16+
// and rpm/ after this script runs in the deploy job, but they are excluded
17+
// here too so local full builds behave identically. Note that out/packages/ is
18+
// NOT excluded: it is the exported /packages download page; the workflow only
19+
// adds release-info.json (not a page) next to it.
20+
//
21+
// Pages that exist but must not be advertised are handled by isNoindex()
22+
// rather than by name. That covers both forms the not-found route takes - with
23+
// trailingSlash the export writes out/404/index.html alongside out/404.html,
24+
// and the App Router adds out/_not-found/index.html - and anything else the
25+
// framework starts emitting later. Listing a noindex URL is what earns the
26+
// "submitted URL marked noindex" warning in Search Console, and a page already
27+
// states that about itself, so there is no second list to keep in sync.
2128
const excludedTopLevelDirectories = new Set([
2229
'_next',
2330
'deb',
2431
'rpm',
2532
'images',
26-
'404',
2733
]);
2834

35+
let noindexPagesSkipped = 0;
36+
37+
/**
38+
* True when the page asks crawlers not to index it. Reads the meta tag in
39+
* either attribute order and tolerates content lists such as "noindex,nofollow".
40+
*/
41+
function isNoindex(html) {
42+
return (html.match(/<meta\b[^>]*>/gi) ?? []).some(
43+
(tag) =>
44+
/\bname=["']?robots["']?/i.test(tag) &&
45+
/\bcontent=["'][^"']*\bnoindex\b/i.test(tag),
46+
);
47+
}
48+
2949
function xmlEscape(value) {
3050
return value
3151
.replace(/&/g, '&amp;')
@@ -45,10 +65,14 @@ function collectPages(directory, relativePath = '') {
4565
const indexFile = path.join(directory, 'index.html');
4666

4767
if (fs.existsSync(indexFile)) {
48-
pages.push({
49-
url: relativePath === '' ? '/' : `/${relativePath}/`,
50-
lastModified: fs.statSync(indexFile).mtime,
51-
});
68+
if (isNoindex(fs.readFileSync(indexFile, 'utf8'))) {
69+
noindexPagesSkipped += 1;
70+
} else {
71+
pages.push({
72+
url: relativePath === '' ? '/' : `/${relativePath}/`,
73+
lastModified: fs.statSync(indexFile).mtime,
74+
});
75+
}
5276
}
5377

5478
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
@@ -113,4 +137,8 @@ if (fs.existsSync(robotsPath)) {
113137
fs.writeFileSync(robotsPath, `User-agent: *\nAllow: /\n\n${sitemapLine}\n`);
114138
}
115139

116-
console.log(`Wrote out/sitemap.xml with ${pages.length} URLs and advertised it in out/robots.txt.`);
140+
console.log(
141+
`Wrote out/sitemap.xml with ${pages.length} URLs (skipped ${noindexPagesSkipped} noindex ${
142+
noindexPagesSkipped === 1 ? 'page' : 'pages'
143+
}) and advertised it in out/robots.txt.`,
144+
);

0 commit comments

Comments
 (0)