diff --git a/app/components/MovedNotice.tsx b/app/components/MovedNotice.tsx new file mode 100644 index 0000000..3dc9597 --- /dev/null +++ b/app/components/MovedNotice.tsx @@ -0,0 +1,56 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; + +type MovedNoticeProps = { + /** Where this page's content now lives. */ + href: string; + /** Human-readable name of the destination page. */ + title: string; +}; + +/** + * Stands in for a page whose content has been superseded by another page. + * + * The obvious implementation is `redirect()` in the server component, and this + * replaces exactly that. It cannot work here: the site is exported as static + * HTML (`output: "export"`), so there is no server to issue a 3xx, and Next + * renders the thrown redirect as an error document instead. The result was a + * page that returned HTTP 200 with an empty `__next_error__` body while still + * being listed in sitemap.xml and linked from two live pages, so both readers + * and crawlers hit a dead end. + * + * Rendering real markup serves both: a crawler sees a working link to the + * replacement, and a reader who followed a bookmark is forwarded once the + * bundle hydrates. + */ +export default function MovedNotice({ href, title }: MovedNoticeProps) { + const router = useRouter(); + + useEffect(() => { + router.replace(href); + }, [href, router]); + + return ( +
+
+

This page has moved

+

+ Its content now lives in{" "} + + {title} + + . You should be redirected automatically. +

+ + Continue to {title} + +
+
+ ); +} diff --git a/app/docs/[section]/[[...slug]]/page.tsx b/app/docs/[section]/[[...slug]]/page.tsx index 7a0a5dc..363c487 100644 --- a/app/docs/[section]/[[...slug]]/page.tsx +++ b/app/docs/[section]/[[...slug]]/page.tsx @@ -1,11 +1,12 @@ import Link from "next/link"; -import { notFound, redirect } from 'next/navigation'; +import { notFound } from 'next/navigation'; import { capitalCase } from 'change-case'; import { getAllArticlePaths, getArticleByPath } from "../../../services/articleService"; import { getMetadata } from "../../../services/metadataService"; import ComingSoon from "../../../components/ComingSoon"; import CommandSnippet from "../../../components/CommandSnippet"; import Markdown from "../../../components/Markdown"; +import MovedNotice from "../../../components/MovedNotice"; const dockerQuickRunCommand = `docker run -dt --name documentdb \\ -p 10260:10260 \\ @@ -19,6 +20,30 @@ const primerPrimaryLinkClass = const primerSecondaryLinkClass = "font-semibold text-blue-300 transition-colors hover:text-blue-200"; +/** + * Slugs under /docs/getting-started/ that no longer have their own content and + * now point at the page that replaced them. Kept in one place so the metadata + * and the rendered notice can never disagree about where a page went. + */ +const movedGettingStartedPages: Record = { + 'prebuilt-packages': { + href: '/docs/getting-started/packages', + title: 'Linux Packages Quick Start', + }, + 'vscode-extension-guide': { + href: '/docs/getting-started/vscode-quickstart', + title: 'Visual Studio Code Quick Start', + }, +}; + +function getMovedPage(section: string, slug: string[]) { + if (section !== 'getting-started') { + return undefined; + } + + return movedGettingStartedPages[slug[slug.length - 1] ?? '']; +} + export async function generateStaticParams() { const paths = getAllArticlePaths(); @@ -37,6 +62,21 @@ interface PageProps { export async function generateMetadata({ params }: PageProps) { const { section, slug = [] } = await params; + const movedPage = getMovedPage(section, slug); + + // These URLs stay reachable for old bookmarks and inbound links, but the + // content is somewhere else now. Pointing crawlers at the replacement stops + // the two copies from competing, and keeps them out of sitemap.xml, which + // is generated by reading the robots directive out of the built HTML. + if (movedPage) { + return { + title: `${movedPage.title} - DocumentDB Documentation`, + description: `This page has moved to ${movedPage.title}.`, + alternates: { canonical: movedPage.href.endsWith('/') ? movedPage.href : `${movedPage.href}/` }, + robots: { index: false, follow: true }, + }; + } + const articleData = getArticleByPath(section, slug); if (!articleData) { @@ -59,14 +99,10 @@ export async function generateMetadata({ params }: PageProps) { export default async function ArticlePage({ params }: PageProps) { const { section, slug = [] } = await params; - const currentSlug = slug[slug.length - 1]; - - if (section === 'getting-started' && currentSlug === 'prebuilt-packages') { - redirect('/docs/getting-started/packages'); - } + const movedPage = getMovedPage(section, slug); - if (section === 'getting-started' && currentSlug === 'vscode-extension-guide') { - redirect('/docs/getting-started/vscode-quickstart'); + if (movedPage) { + return ; } const articleData = getArticleByPath(section, slug); diff --git a/app/lib/releaseInfo.ts b/app/lib/releaseInfo.ts index 0171b0b..6afc8a4 100644 --- a/app/lib/releaseInfo.ts +++ b/app/lib/releaseInfo.ts @@ -18,8 +18,18 @@ export type ReleaseInfo = { aptVersion: string; /** Extension package version on RPM, e.g. "0.116.0-1.el9". */ rpmVersion: string; - /** Version of every non-extension package, e.g. "0.116.0". */ + /** + * Version of every non-extension package, e.g. "0.116.0". + * + * The extension keeps the control-file form (`0.116-0`) while the meta, + * per-major, gateway, tools and common packages use the flat dotted form. + * Pinning examples MUST pick the right one for the package being pinned: + * `apt install documentdb-18=0.116-0` fails with "Version '0.116-0' for + * 'documentdb-18' was not found", because that package is `0.116.0`. + */ metaVersion: string; + /** RPM form of the non-extension packages, e.g. "0.116.0-1". */ + metaRpmVersion: string; releaseUrl: string; assetNames: readonly string[]; }; @@ -33,6 +43,7 @@ export const FALLBACK_RELEASE: ReleaseInfo = { aptVersion: "0.116-0", rpmVersion: "0.116.0-1.el9", metaVersion: "0.116.0", + metaRpmVersion: "0.116.0-1", releaseUrl: "https://github.com/documentdb/documentdb/releases/tag/v0.116-0", assetNames: [], }; @@ -103,7 +114,20 @@ export function parseReleaseInfo(payload: unknown): ReleaseInfo { firstMatch(names, /^documentdb-(\d+\.\d+\.\d+)-\d+\.noarch\.rpm$/) ?? FALLBACK_RELEASE.metaVersion; - return { tagName, aptVersion, rpmVersion, metaVersion, releaseUrl, assetNames: names }; + // e.g. documentdb-0.116.0-1.noarch.rpm -> 0.116.0-1 + const metaRpmVersion = + firstMatch(names, /^documentdb-(\d+\.\d+\.\d+-\d+)\.noarch\.rpm$/) ?? + FALLBACK_RELEASE.metaRpmVersion; + + return { + tagName, + aptVersion, + rpmVersion, + metaVersion, + metaRpmVersion, + releaseUrl, + assetNames: names, + }; } /** diff --git a/app/packages/page.tsx b/app/packages/page.tsx index 76ae72d..894d735 100644 --- a/app/packages/page.tsx +++ b/app/packages/page.tsx @@ -535,27 +535,33 @@ export default function PackagesPage() {

- Use the commands below to discover available versions before pinning. The - examples name the extension package; substitute{" "} - {selectedPackageNames} to pin the package - your selected target actually installs. Replace{" "} - <VERSION> with the version string - shown by the list command (e.g.{" "} - {repoAptVersionExample} for APT,{" "} - {repoRpmVersionExample} for RPM). The - package repositories can lag the newest GitHub release, so pin to a version the - list command actually shows. + Use the commands below to discover available versions before pinning, and pin{" "} + {selectedPackageNames} — the package your + selected target actually installs. +

+

+ The two package families carry different version strings, so the + right <VERSION> depends on which you + pin. The extension is{" "} + {repoAptVersionExample} (APT) /{" "} + {repoRpmVersionExample} (RPM), while{" "} + documentdb,{" "} + documentdb-<pg> and the gateway are{" "} + {release.metaVersion} (APT) /{" "} + {release.metaRpmVersion} (RPM). Pinning{" "} + documentdb-18={repoAptVersionExample}{" "} + fails — always take the string the list command prints.

APT — list then pin

- apt-cache madison postgresql-18-documentdb + apt-cache madison {selectedPackageNames}
- sudo apt install postgresql-18-documentdb=<VERSION> + sudo apt install {selectedPackageNames}=<VERSION>
@@ -563,12 +569,12 @@ export default function PackagesPage() {

RPM — list then pin

- dnf --showduplicates list postgresql18-documentdb + dnf --showduplicates list {selectedPackageNames}
- sudo dnf install postgresql18-documentdb-<VERSION> + sudo dnf install {selectedPackageNames}-<VERSION>
diff --git a/app/services/articleService.ts b/app/services/articleService.ts index 7a9e947..cfc85b9 100644 --- a/app/services/articleService.ts +++ b/app/services/articleService.ts @@ -33,14 +33,18 @@ docker run -dt --name documentdb \\ -p 10260:10260 \\ ghcr.io/documentdb/documentdb/documentdb-local:latest \\ --username \\ - --password + --password \\ + --init-data true \`\`\` > Replace \`\` and \`\` with your own credentials. > -> DocumentDB Local loads built-in sample data into \`sampledb\` by default. See -> [DocumentDB Local](/docs/documentdb-local) for \`--skip-init-data\`, -> \`--init-data-path\`, certificate setup, and additional runtime options. +> \`--init-data true\` seeds the built-in sample data into \`sampledb\`, which the +> verification step below queries. It is **not** enabled by default — without it the +> container starts with no \`sampledb\` and \`use sampledb\` returns nothing. The data is +> seeded once per data volume; re-create the volume to seed again. See +> [DocumentDB Local](/docs/documentdb-local) for \`--init-data-path\`, certificate setup, +> and additional runtime options. ## Verify the container @@ -80,7 +84,7 @@ If you prefer certificate validation instead of \`--tlsAllowInvalidCertificates\ The quick start command above is ideal for disposable local environments. When you need more control: - Use \`--data-path\` with a mounted host directory to keep data across container restarts -- Use \`--skip-init-data\` if you want an empty instance instead of the default \`sampledb\` collections +- Omit \`--init-data true\` if you want an empty instance instead of the \`sampledb\` collections - Use \`--init-data-path\` to run your own \`.js\` initialization scripts with \`mongosh\` at startup The built-in sample dataset includes \`users\`, \`products\`, \`orders\`, and \`analytics\` collections in \`sampledb\`. @@ -946,17 +950,46 @@ If \`mongosh\` does not connect on the first try: - [Samples Gallery](/samples) `; -const documentdbLocalDataInitializationContent = `## Data initialization +const documentdbLocalDataInitializationContent = `## Container image tags + +The \`latest\` tag is a convenience alias. Pin an explicit tag for anything reproducible: + +| Tag | Contents | +|---|---| +| \`ghcr.io/documentdb/documentdb/documentdb-local:pg18-0.116.0\` | DocumentDB 0.116.0 on PostgreSQL 18 | +| \`…:pg17-0.116.0\` | DocumentDB 0.116.0 on PostgreSQL 17 | +| \`…:pg16-0.116.0\` · \`…:pg15-0.116.0\` | PostgreSQL 16 and 15 | +| \`…:latest\` | Currently identical to \`pg17-0.116.0\` | + +> \`latest\` tracks **PostgreSQL 17**, while the \`documentdb\` package on Linux pins +> **PostgreSQL 18**. If you evaluate in Docker and then deploy from packages, you change +> major version unless you pin the tag deliberately. + +Every image records what it was built from: + +\`\`\`bash +docker run --rm --entrypoint cat ghcr.io/documentdb/documentdb/documentdb-local:pg18-0.116.0 /version.txt +\`\`\` + +## Data initialization + +DocumentDB Local starts **empty**. Pass \`--init-data true\` to seed a \`sampledb\` database +with the \`users\`, \`products\`, \`orders\`, and \`analytics\` collections: + +\`\`\`bash +docker run -dt -p 10260:10260 --name documentdb \\ + ghcr.io/documentdb/documentdb/documentdb-local:latest \\ + --username --password --init-data true +\`\`\` -DocumentDB Local starts with built-in sample data by default. The container creates a -\`sampledb\` database with the \`users\`, \`products\`, \`orders\`, and \`analytics\` -collections so you can explore queries right away. +Seeding happens once per data volume, on a fresh volume. Re-create the volume to seed again. ### Control initialization behavior | Requirement | Arg | Env | Default | Description | |---|---|---|---|---| -| Skip built-in sample data | \`--skip-init-data\` | \`SKIP_INIT_DATA\` | \`false\` | Start without loading the default sample collections. | +| Load built-in sample data | \`--init-data [true\\|false]\` | \`INIT_DATA\` | \`false\` | Seed the \`sampledb\` sample collections on a fresh data volume. | +| Skip built-in sample data | \`--skip-init-data\` | \`SKIP_INIT_DATA\` | — | Legacy alias for \`--init-data false\`. Does not affect \`--init-data-path\`. | | Run custom initialization scripts | \`--init-data-path [PATH]\` | \`INIT_DATA_PATH\` | \`/init_doc_db.d\` | Execute every \`.js\` file in the mounted directory with \`mongosh\`. | The built-in sample dataset currently includes 5 users, 5 products, 4 orders, and 2 diff --git a/scripts/generate-sitemap.mjs b/scripts/generate-sitemap.mjs index b4e4f3e..e134566 100644 --- a/scripts/generate-sitemap.mjs +++ b/scripts/generate-sitemap.mjs @@ -33,6 +33,7 @@ const excludedTopLevelDirectories = new Set([ ]); let noindexPagesSkipped = 0; +const brokenPages = []; /** * True when the page asks crawlers not to index it. Reads the meta tag in @@ -46,6 +47,20 @@ function isNoindex(html) { ); } +/** + * Detects a page that rendered as Next's error document rather than as content. + * + * A server-side `redirect()` in a statically exported route cannot redirect — + * there is no server — so Next emits an error document instead. It is served + * with HTTP 200 and a `` shell, which means neither a + * status-code link check nor a crawler can tell it from a real page. Two such + * routes were listed in this sitemap and linked from live pages before this + * check existed. Fail the build rather than advertise them again. + */ +function isErrorDocument(html) { + return /]*\bid=["']__next_error__["']/i.test(html); +} + function xmlEscape(value) { return value .replace(/&/g, '&') @@ -65,7 +80,10 @@ function collectPages(directory, relativePath = '') { const indexFile = path.join(directory, 'index.html'); if (fs.existsSync(indexFile)) { - if (isNoindex(fs.readFileSync(indexFile, 'utf8'))) { + const html = fs.readFileSync(indexFile, 'utf8'); + if (isErrorDocument(html)) { + brokenPages.push(relativePath === '' ? '/' : `/${relativePath}/`); + } else if (isNoindex(html)) { noindexPagesSkipped += 1; } else { pages.push({ @@ -97,6 +115,21 @@ if (!fs.existsSync(outDir)) { const pages = collectPages(outDir).sort((a, b) => a.url.localeCompare(b.url)); +if (brokenPages.length > 0) { + console.error( + [ + 'These routes rendered as Next error documents rather than as pages:', + ...brokenPages.map((url) => ` ${url}`), + '', + 'They would be served with HTTP 200 and an empty body, so neither a status-code', + 'link check nor a crawler can tell them from real pages. The usual cause is a', + 'server-side redirect() in a statically exported route, which cannot redirect.', + 'Render a page instead (see app/components/MovedNotice.tsx).', + ].join('\n'), + ); + process.exit(1); +} + if (pages.length === 0) { console.error('No pages found in out/ - refusing to write an empty sitemap.'); process.exit(1);