This is an automated email from the ASF dual-hosted git repository.
kparisa pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iggy-website.git
The following commit(s) were added to refs/heads/main by this push:
new 4d91901b feat(docs): publish a Markdown copy of every docs page (#79)
4d91901b is described below
commit 4d91901b5f3128db44a48d28cb6ff0880d3b4db1
Author: Justin Mclean <[email protected]>
AuthorDate: Thu Sep 3 16:17:02 2026 +1000
feat(docs): publish a Markdown copy of every docs page (#79)
> Stacked on #77 as it edits `llms.txt`, so it needs that one merged
first.
An agent that wants a docs page has to parse the rendered HTML today.
This writes a Markdown copy next to each page at build time, so
`/docs/server/security/` is also available as
`/docs/server/security.md`.
- `scripts/generate-docs-markdown.mjs` runs in `prebuild` after the
stars fetch and rewrites the copies from `content/docs` on every build.
Generated files are gitignored, like `src/github-stars.json`, so they
can't drift from the source.
- Each docs page advertises its copy with `<link rel="alternate"
type="text/markdown">`, and `llms.txt` states the convention.
- The `.md` files aren't in the sitemap. They're page alternates, not
pages.
`npm run build`, then: 76 `.md` files in `out/`, the `rel="alternate"`
and `rel="canonical"` links both present on a docs page, `index.html`
count unchanged, and `git status` clean.
---------
Co-authored-by: Kranti Parisa <[email protected]>
---
.gitignore | 2 +
package.json | 4 +-
scripts/generate-docs-markdown.mjs | 109 ++++++++++++++++++++++++++++++++
src/app/(home)/page.tsx | 6 +-
src/app/(site)/blogs/[...slug]/page.tsx | 10 +++
src/app/docs/[[...slug]]/page.tsx | 6 ++
src/app/layout.tsx | 82 +++++++++++++++++++++++-
src/app/llms.txt/route.ts | 76 ++++++++++++++++++++++
src/app/not-found.tsx | 33 ++++++++++
src/app/robots.ts | 31 +++++++++
src/app/sitemap.ts | 48 ++++++++++++++
src/lib/blog.ts | 48 ++++++++++++++
src/lib/site.ts | 52 +++++++++++++++
13 files changed, 500 insertions(+), 7 deletions(-)
diff --git a/.gitignore b/.gitignore
index 043d44f7..69ecf6f9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,6 +12,8 @@ tsconfig.tsbuildinfo
# Build-time generated
src/github-stars.json
+public/docs.md
+public/docs/**/*.md
# System files
.DS_Store
diff --git a/package.json b/package.json
index 04633f5e..1bca9fdb 100644
--- a/package.json
+++ b/package.json
@@ -3,9 +3,9 @@
"version": "0.1.0",
"private": true,
"scripts": {
- "predev": "node scripts/fetch-github-stars.mjs",
+ "predev": "node scripts/fetch-github-stars.mjs && node
scripts/generate-docs-markdown.mjs",
"dev": "next dev",
- "prebuild": "node scripts/fetch-github-stars.mjs",
+ "prebuild": "node scripts/fetch-github-stars.mjs && node
scripts/generate-docs-markdown.mjs",
"build": "next build",
"start": "next start",
"postinstall": "fumadocs-mdx"
diff --git a/scripts/generate-docs-markdown.mjs
b/scripts/generate-docs-markdown.mjs
new file mode 100644
index 00000000..9b5628c3
--- /dev/null
+++ b/scripts/generate-docs-markdown.mjs
@@ -0,0 +1,109 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * Emits a Markdown copy of every docs page next to its HTML route, so an agent
+ * can fetch `/docs/server/security.md` instead of parsing the rendered page.
+ *
+ * The site is a static export served by ASF infrastructure, so it cannot do
+ * Accept-header content negotiation; a sibling file is the version of this
that
+ * needs no server. Output goes to public/, is gitignored, and is rebuilt from
+ * content/docs on every build.
+ */
+
+import { readdirSync, readFileSync, writeFileSync, mkdirSync, rmSync, statSync
} from "fs";
+import { join, relative, dirname, basename } from "path";
+
+const CONTENT = "content/docs";
+const PUBLIC = "public";
+const SITE_URL = "https://iggy.apache.org";
+const REPO_BLOB = "https://github.com/apache/iggy-website/blob/main";
+
+function walk(dir) {
+ return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
+ const path = join(dir, entry.name);
+ if (entry.isDirectory()) return walk(path);
+ return entry.name.endsWith(".mdx") ? [path] : [];
+ });
+}
+
+/** content/docs/server/security.mdx -> public/docs/server/security.md
+ * content/docs/binary-protocol/index.mdx -> public/docs/binary-protocol.md
+ * content/docs/index.mdx -> public/docs.md */
+function outputPath(source) {
+ const rel = relative(CONTENT, source).replace(/\.mdx$/, "");
+ const stem = basename(rel) === "index" ? dirname(rel) : rel;
+ return join(PUBLIC, "docs" + (stem === "." ? "" : `/${stem}`) + ".md");
+}
+
+/** Frontmatter is YAML, but only two scalar keys are read here, so it is not
+ * worth a parser dependency. */
+function splitFrontmatter(raw) {
+ if (!raw.startsWith("---\n")) return { meta: {}, body: raw };
+ const end = raw.indexOf("\n---", 3);
+ if (end === -1) return { meta: {}, body: raw };
+ const meta = {};
+ for (const line of raw.slice(4, end).split("\n")) {
+ const match = /^(title|description):\s*(.*)$/.exec(line);
+ if (match) meta[match[1]] = match[2].trim().replace(/^["']|["']$/g, "");
+ }
+ return { meta, body: raw.slice(end + 4).replace(/^\n+/, "") };
+}
+
+function render(source, raw) {
+ const { meta, body } = splitFrontmatter(raw);
+ const url = `${SITE_URL}/${outputPath(source).replace(/^public\//,
"").replace(/\.md$/, "")}/`;
+ const head = [
+ `# ${meta.title ?? basename(source, ".mdx")}`,
+ ...(meta.description ? [`> ${meta.description}`] : []),
+ `Rendered page: ${url}`,
+ `Source: ${REPO_BLOB}/${source}`,
+ ].join("\n\n");
+ // The body keeps its MDX as authored: custom components appear as tags,
which
+ // is honest about what the page contains and needs no MDX pipeline here.
+ return `${head}\n\n${body.trimEnd()}\n`;
+}
+
+/** Everything this script wrote last time. Nothing else under public/ is .md.
*/
+function clean(dir) {
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
+ const path = join(dir, entry.name);
+ if (entry.isDirectory()) clean(path);
+ else if (entry.name.endsWith(".md")) rmSync(path);
+ }
+}
+
+function main() {
+ const sources = walk(CONTENT).sort();
+ try {
+ if (statSync(join(PUBLIC, "docs")).isDirectory()) clean(join(PUBLIC,
"docs"));
+ } catch {
+ // No public/docs yet; nothing to clean.
+ }
+ rmSync(join(PUBLIC, "docs.md"), { force: true });
+
+ for (const source of sources) {
+ const target = outputPath(source);
+ mkdirSync(dirname(target), { recursive: true });
+ writeFileSync(target, render(source, readFileSync(source, "utf8")));
+ }
+ console.log(`Generated ${sources.length} Markdown copies of docs pages.`);
+}
+
+main();
diff --git a/src/app/(home)/page.tsx b/src/app/(home)/page.tsx
index 3ea052f7..9c5c08ba 100644
--- a/src/app/(home)/page.tsx
+++ b/src/app/(home)/page.tsx
@@ -25,7 +25,7 @@ import { BenchmarkSection } from
"@/components/benchmark-chart";
import { LATENCY_MS } from "@/lib/benchmark";
export const metadata: Metadata = {
- title: "Apache Iggy | Hyper-Efficient Message Streaming written in Rust.",
+ title: "Hyper-Efficient Message Streaming written in Rust",
description:
"Apache Iggy is a high-performance, persistent message streaming platform
written in Rust, capable of processing millions of messages per second with
ultra-low latency.",
};
@@ -359,9 +359,9 @@ export default function HomePage() {
{item.step}
</div>
<div className="min-w-0">
- <h4 className="text-base font-semibold text-[#fffaeb]
mb-1">
+ <h3 className="text-base font-semibold text-[#fffaeb]
mb-1">
{item.title}
- </h4>
+ </h3>
<p className="text-sm font-light leading-relaxed
text-[#aeb5bd] m-0">
{item.desc}
</p>
diff --git a/src/app/(site)/blogs/[...slug]/page.tsx
b/src/app/(site)/blogs/[...slug]/page.tsx
index 70c9699a..9a7c563a 100644
--- a/src/app/(site)/blogs/[...slug]/page.tsx
+++ b/src/app/(site)/blogs/[...slug]/page.tsx
@@ -148,8 +148,18 @@ export async function generateMetadata(props: {
const page = findPostByDateSlug(params.slug);
if (!page) notFound();
+ const date = new Date(page.date);
+ const year = date.getFullYear();
+ const month = String(date.getMonth() + 1).padStart(2, "0");
+ const day = String(date.getDate()).padStart(2, "0");
+
return {
title: page.title,
description: page.description,
+ // Set explicitly: the inherited relative canonical loses the trailing
slash
+ // on this catch-all route, so it would not match the URL actually served.
+ alternates: {
+ canonical: `/blogs/${year}/${month}/${day}/${getSlug(page.info.path)}/`,
+ },
};
}
diff --git a/src/app/docs/[[...slug]]/page.tsx
b/src/app/docs/[[...slug]]/page.tsx
index efb85f5c..bd43140b 100644
--- a/src/app/docs/[[...slug]]/page.tsx
+++ b/src/app/docs/[[...slug]]/page.tsx
@@ -79,6 +79,12 @@ export async function generateMetadata(props: {
return {
title: page.data.title,
description: page.data.description,
+ alternates: {
+ // Set both: defining alternates here replaces the root's, canonical
included.
+ canonical: `${page.url}/`,
+ // The Markdown copy written by scripts/generate-docs-markdown.mjs.
+ types: { "text/markdown": `${page.url}.md` },
+ },
};
}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index f0c4be74..06d6e179 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -21,22 +21,100 @@ import { RootProvider } from "fumadocs-ui/provider/next";
import type { Metadata } from "next";
import Script from "next/script";
import { Matomo } from "@/components/matomo";
+import { SITE_DESCRIPTION, SITE_URL } from "@/lib/site";
import "./global.css";
+const OG_IMAGE = "/img/apache-iggy-color-darkbg0.5x.png";
+
export const metadata: Metadata = {
+ metadataBase: new URL(SITE_URL),
title: {
default: "Apache Iggy",
template: "%s | Apache Iggy",
},
- description:
- "Apache Iggy is a persistent message streaming platform written in Rust,
supporting QUIC, TCP and HTTP transport protocols, capable of processing
millions of messages per second.",
+ description: SITE_DESCRIPTION,
+ // Relative values resolve against the current route, so every page gets its
own canonical.
+ alternates: { canonical: "./" },
+ // No title/description here on purpose: Next fills og:title and
og:description
+ // from each page's own metadata, so per-page titles survive.
+ openGraph: {
+ type: "website",
+ siteName: "Apache Iggy",
+ url: "./",
+ images: [
+ {
+ url: OG_IMAGE,
+ width: 1951,
+ height: 652,
+ alt: "Apache Iggy",
+ },
+ ],
+ },
+ twitter: {
+ card: "summary_large_image",
+ images: [OG_IMAGE],
+ },
icons: { icon: "/img/favicon.png" },
};
+// Structured data. Kept deliberately small: only claims the site already
makes elsewhere.
+const structuredData = {
+ "@context": "https://schema.org",
+ "@graph": [
+ {
+ "@type": "Organization",
+ "@id": `${SITE_URL}/#organization`,
+ name: "The Apache Software Foundation",
+ url: "https://www.apache.org/",
+ logo: `${SITE_URL}/img/asf_logo.svg`,
+ contactPoint: {
+ "@type": "ContactPoint",
+ contactType: "technical support",
+ email: "[email protected]",
+ url: `${SITE_URL}/community/`,
+ },
+ },
+ {
+ "@type": "WebSite",
+ "@id": `${SITE_URL}/#website`,
+ name: "Apache Iggy",
+ url: `${SITE_URL}/`,
+ description: SITE_DESCRIPTION,
+ inLanguage: "en",
+ publisher: { "@id": `${SITE_URL}/#organization` },
+ },
+ {
+ "@type": "SoftwareApplication",
+ "@id": `${SITE_URL}/#software`,
+ name: "Apache Iggy",
+ applicationCategory: "DeveloperApplication",
+ description: SITE_DESCRIPTION,
+ url: `${SITE_URL}/`,
+ license: "https://www.apache.org/licenses/LICENSE-2.0",
+ sameAs: ["https://github.com/apache/iggy"],
+ publisher: { "@id": `${SITE_URL}/#organization` },
+ },
+ ],
+};
+
+/**
+ * JSON-LD has to be injected as raw text: React would HTML-escape it as a
child
+ * and the quotes would end up as entities. The object above is built from
+ * constants in this file, so nothing here is user input, but JSON.stringify
does
+ * not escape "<" -- so escape it, and a stray "</script>" can never close the
tag.
+ */
+function serializeJsonLd(data: unknown): string {
+ return JSON.stringify(data).replace(/</g, "\\u003c");
+}
+
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<body className="flex min-h-screen flex-col font-sans antialiased">
+ <script
+ type="application/ld+json"
+ dangerouslySetInnerHTML={{ __html: serializeJsonLd(structuredData) }}
+ />
<RootProvider
search={{ options: { type: "static" as const } }}
theme={{ defaultTheme: "dark" }}
diff --git a/src/app/llms.txt/route.ts b/src/app/llms.txt/route.ts
new file mode 100644
index 00000000..5fe8a4d0
--- /dev/null
+++ b/src/app/llms.txt/route.ts
@@ -0,0 +1,76 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { llms } from "fumadocs-core/source/llms";
+import { source } from "@/lib/source";
+import { publishedPosts } from "@/lib/blog";
+import { SITE_DESCRIPTION, SITE_URL, absoluteUrl } from "@/lib/site";
+
+export const dynamic = "force-static";
+
+const INTRO = `# Apache Iggy
+
+> ${SITE_DESCRIPTION}
+
+Apache Iggy is a message streaming server, not a hosted service: there is no
+API on this domain to call. This site carries the documentation, blog and
+community pages. The source, releases and issue tracker live at
+https://github.com/apache/iggy.
+
+## When to use this site
+
+- Running or configuring the server, including storage, networking and
clustering: ${absoluteUrl("/docs/server")} and
${absoluteUrl("/docs/clustering")}
+- Writing a producer or consumer in a given language:
${absoluteUrl("/docs/sdk")}
+- Talking to the server directly over QUIC, TCP or HTTP:
${absoluteUrl("/docs/binary-protocol")}
+- Moving data in or out of Iggy without writing code:
${absoluteUrl("/docs/connectors")}
+- Command line and web administration: ${absoluteUrl("/docs/cli")} and
${absoluteUrl("/docs/web_ui")}
+- Downloading a release: ${absoluteUrl("/downloads")}
+- Contributing, or reaching the project's mailing lists and Discord:
${absoluteUrl("/community")}
+
+Every documentation page has a Markdown copy at the same path with a .md
+suffix, so ${absoluteUrl("/docs/server/security")} is also served as
+${SITE_URL}/docs/server/security.md.
+`;
+
+export function GET(): Response {
+ const posts = publishedPosts()
+ .slice(0, 10)
+ .map(({ post, href, date }) => {
+ const day = date.toISOString().slice(0, 10);
+ return `- [${post.title}](${absoluteUrl(href)}): ${day}`;
+ })
+ .join("\n");
+
+ // The helper emits site-relative links; llms.txt consumers want absolute
ones.
+ const docsIndex = llms(source)
+ .index()
+ .replace(/\]\((\/[^)\s]*)\)/g, (_match, path: string) =>
`](${absoluteUrl(path)})`)
+ // One H1 per file: the helper's top-level heading becomes a section.
+ .replace(/^# /gm, "## ");
+
+ const body = [
+ INTRO,
+ docsIndex,
+ `## Blog\n\nThe ten most recent posts. The full list is at
${absoluteUrl("/blogs")}.\n\n${posts}\n`,
+ ].join("\n");
+
+ return new Response(body, {
+ headers: { "Content-Type": "text/plain; charset=utf-8" },
+ });
+}
diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx
index 271125c8..4ca911dd 100644
--- a/src/app/not-found.tsx
+++ b/src/app/not-found.tsx
@@ -24,6 +24,14 @@ export const metadata: Metadata = {
title: "404: Page not found",
};
+const RECOVERY_LINKS = [
+ { href: "/", label: "Home" },
+ { href: "/docs", label: "Documentation" },
+ { href: "/blogs", label: "Blog" },
+ { href: "/downloads", label: "Downloads" },
+ { href: "/community", label: "Community" },
+];
+
export default function NotFound() {
return (
<main className="flex min-h-screen flex-col items-center justify-center
gap-4 px-6 text-center">
@@ -42,6 +50,31 @@ export default function NotFound() {
>
Back to documentation
</Link>
+ <nav
+ aria-label="Where to go next"
+ className="mt-6 flex flex-wrap items-center justify-center gap-x-6
gap-y-2 text-sm text-fd-muted-foreground"
+ >
+ {RECOVERY_LINKS.map((link) => (
+ <Link
+ key={link.href}
+ href={link.href}
+ className="transition-colors hover:text-fd-primary"
+ >
+ {link.label}
+ </Link>
+ ))}
+ </nav>
+ <p className="mt-6 max-w-md text-xs text-fd-muted-foreground">
+ Every page on this site is listed in{" "}
+ <a href="/sitemap.xml" className="underline hover:text-fd-primary">
+ /sitemap.xml
+ </a>
+ , and{" "}
+ <a href="/llms.txt" className="underline hover:text-fd-primary">
+ /llms.txt
+ </a>{" "}
+ summarises the documentation.
+ </p>
</main>
);
}
diff --git a/src/app/robots.ts b/src/app/robots.ts
new file mode 100644
index 00000000..24b58ab5
--- /dev/null
+++ b/src/app/robots.ts
@@ -0,0 +1,31 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import type { MetadataRoute } from "next";
+import { SITE_URL } from "@/lib/site";
+
+export const dynamic = "force-static";
+
+export default function robots(): MetadataRoute.Robots {
+ return {
+ rules: [{ userAgent: "*", allow: "/" }],
+ sitemap: `${SITE_URL}/sitemap.xml`,
+ host: SITE_URL,
+ };
+}
diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts
new file mode 100644
index 00000000..69ab9a9c
--- /dev/null
+++ b/src/app/sitemap.ts
@@ -0,0 +1,48 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import type { MetadataRoute } from "next";
+import { source } from "@/lib/source";
+import { publishedPosts } from "@/lib/blog";
+import { STATIC_ROUTES, absoluteUrl } from "@/lib/site";
+
+export const dynamic = "force-static";
+
+export default function sitemap(): MetadataRoute.Sitemap {
+ const staticPages = STATIC_ROUTES.map((route) => ({
+ url: absoluteUrl(route),
+ changeFrequency: "weekly" as const,
+ priority: route === "/" ? 1 : 0.7,
+ }));
+
+ const docsPages = source.getPages().map((page) => ({
+ url: absoluteUrl(page.url),
+ changeFrequency: "weekly" as const,
+ priority: 0.8,
+ }));
+
+ const blogPages = publishedPosts().map(({ href, date }) => ({
+ url: absoluteUrl(href),
+ lastModified: date,
+ changeFrequency: "yearly" as const,
+ priority: 0.6,
+ }));
+
+ return [...staticPages, ...docsPages, ...blogPages];
+}
diff --git a/src/lib/blog.ts b/src/lib/blog.ts
new file mode 100644
index 00000000..ec55ca18
--- /dev/null
+++ b/src/lib/blog.ts
@@ -0,0 +1,48 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { blogPosts } from "@/lib/source";
+
+/** Filename stem of a blog post, which is the last segment of its URL. */
+export function blogSlug(filePath: string): string {
+ return (
+ filePath
+ .replace(/\.mdx?$/, "")
+ .split("/")
+ .pop() ?? filePath
+ );
+}
+
+/** Published, non-draft posts, newest first, with their site-relative URLs. */
+export function publishedPosts() {
+ return [...blogPosts]
+ .filter((post) => !post.draft)
+ .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
+ .map((post) => {
+ const date = new Date(post.date);
+ const year = date.getFullYear();
+ const month = String(date.getMonth() + 1).padStart(2, "0");
+ const day = String(date.getDate()).padStart(2, "0");
+ return {
+ post,
+ date,
+ href: `/blogs/${year}/${month}/${day}/${blogSlug(post.info.path)}`,
+ };
+ });
+}
diff --git a/src/lib/site.ts b/src/lib/site.ts
new file mode 100644
index 00000000..8e067b46
--- /dev/null
+++ b/src/lib/site.ts
@@ -0,0 +1,52 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/** Canonical origin of the published site. Used for canonical URLs, Open
Graph, sitemap and llms.txt. */
+export const SITE_URL = "https://iggy.apache.org";
+
+/** Short description reused across metadata, structured data and llms.txt. */
+export const SITE_DESCRIPTION =
+ "Apache Iggy is a persistent message streaming platform written in Rust,
supporting QUIC, TCP and HTTP transport protocols, capable of processing
millions of messages per second.";
+
+/**
+ * Pages outside the docs and blog collections.
+ *
+ * Static export means these are the only hand-written routes, so they are
listed
+ * here rather than discovered. Add a route here when you add a page under
+ * `src/app/(site)/`, or it will be missing from the sitemap.
+ */
+export const STATIC_ROUTES = [
+ "/",
+ "/blogs",
+ "/downloads",
+ "/community",
+ "/community/become-a-committer",
+ "/community/brand-kit",
+ "/community/graduation-checklist",
+ "/community/how-to-contribute",
+ "/community/how-to-release",
+ "/community/team",
+] as const;
+
+/** `trailingSlash: true` in next.config.mjs, so every emitted URL ends in a
slash. */
+export function absoluteUrl(path: string): string {
+ const withLeading = path.startsWith("/") ? path : `/${path}`;
+ const withTrailing = withLeading.endsWith("/") ? withLeading :
`${withLeading}/`;
+ return `${SITE_URL}${withTrailing}`;
+}