diff --git a/.gitignore b/.gitignore index 043d44f7c0..69ecf6f9c6 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 04633f5eb5..1bca9fdbd4 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 0000000000..9b5628c317 --- /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 3ea052f709..9c5c08ba7e 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}
-

+

{item.title} -

+

{item.desc}

diff --git a/src/app/(site)/blogs/[...slug]/page.tsx b/src/app/(site)/blogs/[...slug]/page.tsx index 70c9699a7d..9a7c563af8 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 efb85f5cea..bd43140b3c 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 f0c4be7483..06d6e17916 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: "dev@iggy.apache.org", + 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 "" can never close the tag. + */ +function serializeJsonLd(data: unknown): string { + return JSON.stringify(data).replace(/ +