From ec3eaa643781cfdd7825f1baf2a09d8376a87fad Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Tue, 29 Sep 2026 14:13:59 +0200 Subject: [PATCH] fix(web): sitemap and llms.txt list public pages at their canonical /page/ URL (#166) --- .../autopilot-monitor-web/app/about/faq.ts | 2 +- src/Web/autopilot-monitor-web/app/layout.tsx | 4 +- .../settings/tenant/sections/SectionPlan.tsx | 4 +- src/Web/autopilot-monitor-web/app/sitemap.ts | 20 ++++--- src/Web/autopilot-monitor-web/public/llms.txt | 15 +++--- .../utils/__tests__/sitePageUrl.guard.test.ts | 53 +++++++++++++++++++ src/Web/autopilot-monitor-web/utils/config.ts | 13 +++++ 7 files changed, 88 insertions(+), 23 deletions(-) create mode 100644 src/Web/autopilot-monitor-web/utils/__tests__/sitePageUrl.guard.test.ts diff --git a/src/Web/autopilot-monitor-web/app/about/faq.ts b/src/Web/autopilot-monitor-web/app/about/faq.ts index cfe915c7..63057862 100644 --- a/src/Web/autopilot-monitor-web/app/about/faq.ts +++ b/src/Web/autopilot-monitor-web/app/about/faq.ts @@ -57,7 +57,7 @@ export const ABOUT_FAQ: AboutFaqItem[] = [ { question: "Is Autopilot Monitor free and open source?", answer: - "Yes. The Community plan is free and stays free, includes the complete current feature set, and is meant for production fleets, not just labs. The source code is on GitHub: the agent under the MIT license, the backend, portal, and MCP server under AGPL-3.0. A commercial Pro plan is coming for organizations that need reliability commitments, priority support, longer data retention, and delegated administration across customer tenants.", + "Yes. The Community plan is free, stays free, and is meant for production fleets, not just labs: live monitoring, the full rules engine including custom rules, fleet analytics, notifications, diagnostics, and AI integration through MCP within usage limits, with 90-day data retention. The source code is on GitHub: the agent under the MIT license, the backend, portal, and MCP server under AGPL-3.0. The optional Pro plan, sold through Microsoft Marketplace and Cleverbridge, adds 365-day retention, higher API and AI usage limits, delegated administration across customer tenants, OOBE bootstrap sessions, reliability commitments, and priority support; a tenant administrator can try it free for 30 days.", }, { question: "How is the agent deployed, and does it stay on the device?", diff --git a/src/Web/autopilot-monitor-web/app/layout.tsx b/src/Web/autopilot-monitor-web/app/layout.tsx index 376569fb..060973b1 100644 --- a/src/Web/autopilot-monitor-web/app/layout.tsx +++ b/src/Web/autopilot-monitor-web/app/layout.tsx @@ -18,7 +18,7 @@ import AppInsightsInit from "../components/AppInsightsInit"; import ChunkReloadRecovery from "../components/ChunkReloadRecovery"; import { HostRoutingGuard } from "../components/HostRoutingGuard"; import { LegacyPathRedirect } from "../components/LegacyPathRedirect"; -import { API_BASE_URL, DOCS_URL, SITE_URL } from "@/utils/config"; +import { API_BASE_URL, DOCS_URL, SITE_URL, sitePageUrl } from "@/utils/config"; import { AUTH_HINT_INLINE_SCRIPT } from "@/lib/authHint"; const inter = Inter({ subsets: ["latin"] }); @@ -145,7 +145,7 @@ const jsonLd = { name: "Community plan", price: "0", priceCurrency: "EUR", - url: `${SITE_URL}/plans`, + url: sitePageUrl("/plans"), }, author: { "@id": PERSON_ID }, creator: { "@id": PERSON_ID }, diff --git a/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionPlan.tsx b/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionPlan.tsx index 91e9dd35..fab08575 100644 --- a/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionPlan.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionPlan.tsx @@ -5,7 +5,7 @@ import Link from "next/link"; import { useTenantConfig } from "../../TenantConfigContext"; import { isProViaMsp, missingContactProfileParts, trialDaysLeft } from "@/lib/edition"; import { PlanCards } from "@/components/plans/PlanCards"; -import { SITE_URL } from "@/utils/config"; +import { sitePageUrl } from "@/utils/config"; import { SectionCardHeader } from "@/components/SectionCardHeader"; import { DOCS_PATHS } from "@/lib/docsPaths"; @@ -108,7 +108,7 @@ export function SectionPlan() {

Ready to buy? Pro is sold through Microsoft Marketplace and Cleverbridge —{" "} Free, open-source real-time monitoring and troubleshooting for Windows Autopilot enrollments managed through Microsoft Intune. A lightweight, temporary agent streams enrollment events to a web portal, analyze rules flag failure patterns automatically, and a Model Context Protocol (MCP) server lets AI assistants query the data. Created and maintained by Oliver Kieselbach (Microsoft MVP); the hosted service is operated by glueckkanja AG and runs in Azure Germany West Central. -Autopilot Monitor is for IT administrators, helpdesk engineers, and managed service providers who deploy Windows devices with Intune and Autopilot. The Community plan is free and covers the full current feature set; a Pro plan with reliability commitments, priority support, longer retention, and delegated administration across customer tenants is coming. +Autopilot Monitor is for IT administrators, helpdesk engineers, and managed service providers who deploy Windows devices with Intune and Autopilot. The Community plan is free and stays free: live monitoring, the full rules engine, fleet analytics, and AI integration (MCP) within usage limits. The optional Pro plan adds 365-day instead of 90-day retention, higher usage limits, delegated administration across customer tenants, OOBE bootstrap sessions, reliability commitments, and priority support; it is sold through Microsoft Marketplace and Cleverbridge, and a tenant administrator can try it free for 30 days. ## Portal - [Home](https://www.autopilotmonitor.com/): product overview -- [About](https://www.autopilotmonitor.com/about): what it is, how it works, common questions -- [Get started](https://www.autopilotmonitor.com/get-started): first steps for a new tenant -- [Plans](https://www.autopilotmonitor.com/plans): Community vs. Pro -- [Help](https://www.autopilotmonitor.com/help): support channels -- [Privacy Policy](https://www.autopilotmonitor.com/privacy) -- [Terms of Use](https://www.autopilotmonitor.com/terms) +- [About](https://www.autopilotmonitor.com/about/): what it is, how it works, common questions +- [Get started](https://www.autopilotmonitor.com/get-started/): first steps for a new tenant +- [Plans](https://www.autopilotmonitor.com/plans/): Community vs. Pro +- [Get Pro](https://www.autopilotmonitor.com/buy/): purchase options and the free 30-day trial +- [Help](https://www.autopilotmonitor.com/help/): support channels +- [Privacy Policy](https://www.autopilotmonitor.com/privacy/) +- [Terms of Use](https://www.autopilotmonitor.com/terms/) ## Documentation diff --git a/src/Web/autopilot-monitor-web/utils/__tests__/sitePageUrl.guard.test.ts b/src/Web/autopilot-monitor-web/utils/__tests__/sitePageUrl.guard.test.ts new file mode 100644 index 00000000..1b8e945d --- /dev/null +++ b/src/Web/autopilot-monitor-web/utils/__tests__/sitePageUrl.guard.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import nextConfig from "../../next.config"; +import sitemap from "../../app/sitemap"; +import { SITE_URL, sitePageUrl } from "../config"; + +/** + * Public pages are served at /page/ only: the static export writes + * page/index.html (next.config trailingSlash: true), SWA's trailingSlash "auto" + * 301-redirects /page to /page/, and Next emits /page/ as the canonical URL. + * Page URLs handed to crawlers outside Next metadata (sitemap, llms.txt) must + * use that form, or each one is a redirect to a different canonical URL + * (Search Console: "Page with redirect"). + */ + +const WEB_ROOT = join(__dirname, "..", ".."); + +/** Served form: SITE_URL origin, path "/" or "/segment/.../". */ +function expectServedForm(url: string): void { + const parsed = new URL(url); + expect(parsed.origin, url).toBe(SITE_URL); + expect(parsed.pathname, url).toMatch(/^\/(?:[^/]+\/)*$/); +} + +describe("public page URLs use the served /page/ form", () => { + it("the export and SWA serve public pages at /page/", () => { + expect(nextConfig.trailingSlash).toBe(true); + const swa = JSON.parse(readFileSync(join(WEB_ROOT, "staticwebapp.config.json"), "utf-8")); + expect(swa.trailingSlash).toBe("auto"); + }); + + it("sitePageUrl appends the trailing slash exactly once", () => { + expect(sitePageUrl("/")).toBe(`${SITE_URL}/`); + expect(sitePageUrl("/plans")).toBe(`${SITE_URL}/plans/`); + expect(sitePageUrl("/plans/")).toBe(`${SITE_URL}/plans/`); + }); + + it("sitemap lists every page in the served form", () => { + const urls = sitemap().map((entry) => entry.url); + expect(urls.length).toBeGreaterThan(0); + for (const url of urls) expectServedForm(url); + }); + + it("llms.txt links site pages in the served form", () => { + const llms = readFileSync(join(WEB_ROOT, "public", "llms.txt"), "utf-8"); + const links = [...llms.matchAll(/\]\(([^)\s]+)\)/g)] + .map((match) => match[1]) + .filter((url) => new URL(url).origin === SITE_URL); + expect(links.length).toBeGreaterThan(0); + for (const url of links) expectServedForm(url); + }); +}); diff --git a/src/Web/autopilot-monitor-web/utils/config.ts b/src/Web/autopilot-monitor-web/utils/config.ts index 94985aa5..ad39475c 100644 --- a/src/Web/autopilot-monitor-web/utils/config.ts +++ b/src/Web/autopilot-monitor-web/utils/config.ts @@ -29,6 +29,19 @@ export const DOCS_URL = "https://docs.autopilotmonitor.com"; /** Public marketing/product website (also feeds metadataBase/sitemap/robots). */ export const SITE_URL = "https://www.autopilotmonitor.com"; +/** + * Absolute URL of a public page in the /page/ form the site serves: the static + * export writes page/index.html (next.config trailingSlash: true) and SWA + * 301-redirects /page to /page/. Next applies that form to metadata URLs + * (canonical, og:url) itself; every page URL built outside metadata (sitemap, + * JSON-LD, absolute links) goes through here, so crawlers get the canonical URL + * instead of a redirect. sitePageUrl.guard.test.ts pins the coupling. + */ +export function sitePageUrl(path: `/${string}`): string { + const slashed = path.endsWith("/") ? path : `${path}/`; + return `${SITE_URL}${slashed}`; +} + /** Customer portal (deep links rendered into generated bootstrap scripts). */ export const PORTAL_URL = "https://portal.autopilotmonitor.com";