Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/api/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -757,6 +757,8 @@ export type SiteReleaseSnapshot = {
* team's selected theme applies. */
theme?: Record<string, unknown>;
redirects?: { from: string; to: string; permanent?: boolean }[];
/** `site.json`'s Markdown for AI agents, served in `/llms.txt`. */
agentInstructions?: string;
};

/** "preview": built from a pull request's head; never activated. */
Expand Down
3 changes: 3 additions & 0 deletions apps/api/src/pages/git-export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ export async function exportPagesFiles(teamId: string, directory: string) {
footer: release.site.footer,
...(release.site.theme ? { theme: release.site.theme as any } : {}),
...(release.site.redirects ? { redirects: release.site.redirects } : {}),
...(release.site.agentInstructions
? { agentInstructions: release.site.agentInstructions }
: {}),
}),
});
return files;
Expand Down
3 changes: 3 additions & 0 deletions apps/api/src/pages/git-import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,9 @@ export async function createPagesRelease(
? { theme: manifest.site.theme as unknown as Record<string, unknown> }
: {}),
...(manifest.site.redirects ? { redirects: manifest.site.redirects } : {}),
...(manifest.site.agentInstructions
? { agentInstructions: manifest.site.agentInstructions }
: {}),
}
: { header: liveSite.header, footer: liveSite.footer };

Expand Down
1 change: 1 addition & 0 deletions apps/api/src/pages/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,7 @@ export async function listPublishedPages(teamId: string) {
const release = await getActiveRelease(teamId);
return (release?.pages ?? []).map((entry) => ({
slug: entry.slug,
title: entry.revision.content.title || entry.name,
robotsAllowed: entry.revision.content.robotsAllowed !== false,
// A revision is created when its content first appears, so this only
// moves when the page itself changes.
Expand Down
14 changes: 13 additions & 1 deletion apps/api/src/public/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,7 @@ const impl = s.router(contract.public, {
codeInjectionHead,
codeInjectionBody,
redirects: release?.site.redirects ?? [],
agentInstructions: release?.site.agentInstructions ?? null,
canonicalHost: domains?.canonicalHost ?? null,
hideFromSearch: domains?.hideFromSearch ?? false,
},
Expand Down Expand Up @@ -418,34 +419,44 @@ const impl = s.router(contract.public, {
const items: {
kind: "page" | "blog" | "doc" | "changelog";
path: string;
title: string | null;
lastmod: string | null;
}[] = [
...pagesList
.filter((p) => p.robotsAllowed !== false)
.map((p) => ({
kind: "page" as const,
path: `/${p.slug}`,
title: p.title,
lastmod: (p.updatedAt ?? p.publishedAt)?.toISOString() ?? null,
})),
...blogs.map((d) => ({
kind: "blog" as const,
path: `/blog/${d.slug}`,
title: d.title,
lastmod: (d.updatedAt ?? d.publishedAt)?.toISOString() ?? null,
})),
...docs.map((d) => ({
kind: "doc" as const,
path: `/docs/${d.slug}`,
title: d.title,
lastmod: (d.updatedAt ?? d.publishedAt)?.toISOString() ?? null,
})),
];
const apiReference = await readApiReference(team.id);
if (apiReference)
items.push(
{ kind: "doc", path: `/docs/${apiReference.mount}`, lastmod: null },
{
kind: "doc",
path: `/docs/${apiReference.mount}`,
title: apiReference.info.title,
lastmod: null,
},
...apiReference.groups.flatMap((group) =>
group.operations.map((operation) => ({
kind: "doc" as const,
path: `/docs/${operation.route}`,
title: operation.title,
lastmod: null,
})),
),
Expand All @@ -461,6 +472,7 @@ const impl = s.router(contract.public, {
items.push({
kind: "changelog" as const,
path: "/changelog",
title: "Changelog",
lastmod: lastmod?.toISOString() ?? null,
});
}
Expand Down
23 changes: 20 additions & 3 deletions apps/docs/content/docs/developers/pages-from-git.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ Everything FrontLit syncs lives in one site folder, `frontlit/` by default:

```text
frontlit/
├─ site.json # header, footer, theme and redirects (optional)
├─ site.json # header, footer, theme, redirects, agent instructions (optional)
├─ docs/ # Markdown docs → /docs/...
└─ pages/
├─ index.json # /
Expand Down Expand Up @@ -90,10 +90,27 @@ A page file:
"$schema": "https://frontlit.app/schema/site.v1.json",
"header": { "links": [{ "label": "Pricing", "href": "/pricing" }], "ctaLabel": "Sign up", "ctaHref": "/signup" },
"footer": { "tagline": "The front office of your SaaS." },
"redirects": [{ "from": "/plans", "to": "/pricing" }]
"redirects": [{ "from": "/plans", "to": "/pricing" }],
"agentInstructions": "Use this site for questions about Acme's API and pricing."
}
```

`agentInstructions` is optional Markdown for AI agents: what the site is for
and when to use it. It shows as its own section of the site's `/llms.txt`.

## Markdown for AI agents

Every site also serves its public content as Markdown:

- Request any page, docs page, blog post or the changelog with
`Accept: text/markdown`, or add `.md` to its URL (`/docs/setup.md`,
`/index.md` for the homepage). Browsers keep getting HTML.
- A missing page requested as Markdown returns a 404 with a short Markdown
body that links to `/llms.txt` and `/sitemap.xml`.
- `/llms.txt` lists the site's pages, docs, blog posts, changelog and OpenAPI
spec with links to their `.md` URLs. Pages with `"robotsAllowed": false`
are left out, and the list is empty while the site is hidden from search.

## How a push is published

- Each push to the publishing branch is published as one **release**: every
Expand Down Expand Up @@ -132,7 +149,7 @@ Marketing pages live in `frontlit/pages/*.json` and publish on merge.
- File name = URL (`pricing.json` → `/pricing`, `index.json` → `/`). Flat folder only.
- Every file starts with `"$schema": "https://frontlit.app/schema/page.v1.json"`;
validate against it before committing. `frontlit/site.json` (schema
`site.v1.json`) holds the header, footer, theme and redirects.
`site.v1.json`) holds the header, footer, theme, redirects and agent instructions.
- A page is `{ title, description, layout: [{ name, settings? }] }`. Only set
settings that differ from the block defaults listed in the schema.
- Rich text is Markdown. Media is `{ "url", "alt" }` with an `https://` URL or
Expand Down
35 changes: 35 additions & 0 deletions apps/sites/app/sites/[teamId]/agent-markdown/[[...path]]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { getSettings } from "@/lib/api";
import { notFoundMarkdown, renderMarkdown } from "@/lib/markdown";
import { markdownUrl } from "@/lib/markdown-negotiation";

/**
* Markdown for AI agents. The proxy rewrites here for `Accept:
* text/markdown` and `<path>.md` (see lib/markdown.ts); `?md=url` marks a
* `.md` request so redirects stay on `.md` URLs.
*/
export async function GET(
request: Request,
{ params }: { params: Promise<{ teamId: string; path?: string[] }> },
) {
const { teamId, path: segments = [] } = await params;
const path = `/${segments.join("/")}`;
const headers = { "content-type": "text/markdown; charset=utf-8" };
const markdown = await renderMarkdown(teamId, path);
if (markdown) return new Response(markdown, { headers });

// Redirects from site.json only apply where no page exists.
const settings = await getSettings(teamId);
const match = settings?.redirects?.find(
(redirect) => (redirect.from.replace(/\/+$/, "") || "/") === path,
);
if (match) {
const viaUrl = new URL(request.url).searchParams.get("md") === "url";
const location =
viaUrl && match.to.startsWith("/") ? markdownUrl(match.to) : match.to;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve query strings when redirecting Markdown URLs

When a site.json redirect target contains a query string, markdownUrl(match.to) appends .md to the whole string (/new?lang=en becomes /new?lang=en.md). The redirected URL no longer has a .md pathname, so the destination is served as HTML instead of Markdown. Add the suffix to the target pathname while preserving its query and fragment.

Useful? React with 👍 / 👎.

return new Response(null, {
status: match.permanent === false ? 307 : 308,
headers: { location },
});
}
return new Response(notFoundMarkdown(path), { status: 404, headers });
}
19 changes: 19 additions & 0 deletions apps/sites/app/sites/[teamId]/llms.txt/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { getApiReference, getSettings, getSitemap } from "@/lib/api";
import { buildLlmsTxt } from "@/lib/markdown";

/** `/llms.txt`: an index of the site's public content for AI agents. */
export async function GET(
_request: Request,
{ params }: { params: Promise<{ teamId: string }> },
) {
const { teamId } = await params;
const [entries, settings, reference] = await Promise.all([
getSitemap(teamId),
getSettings(teamId),
getApiReference(teamId),
]);
return new Response(
buildLlmsTxt(settings, settings?.hideFromSearch ? [] : entries, Boolean(reference)),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Gate the API link when search visibility is off

When hideFromSearch is true, this passes an empty sitemap entry list but still advertises the OpenAPI spec whenever a reference exists. That leaves a content link in /llms.txt for a hidden site, contrary to the documented behavior that its list is empty. Gate hasOpenApi on the same visibility setting.

Useful? React with 👍 / 👎.

{ headers: { "content-type": "text/plain; charset=utf-8" } },
);
}
3 changes: 3 additions & 0 deletions apps/sites/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ export interface PublicSettings {
codeInjectionHead: string | null;
codeInjectionBody: string | null;
redirects?: { from: string; to: string; permanent?: boolean }[];
/** site.json's Markdown for AI agents, shown in `/llms.txt`. */
agentInstructions?: string | null;
/** Host links, canonical URLs and feeds point at. */
canonicalHost: string | null;
/** Pre-launch: noindex everywhere, robots disallow all. */
Expand Down Expand Up @@ -187,6 +189,7 @@ export function getPreviewPage(
export interface SitemapEntry {
kind: "page" | "blog" | "doc" | "changelog";
path: string;
title?: string | null;
lastmod: string | null;
}

Expand Down
16 changes: 16 additions & 0 deletions apps/sites/lib/edge-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,4 +97,20 @@ describe("edgeCacheHeaders", () => {
).not.toEqual(privateOnly);
}
});

it("never stores Markdown chosen by Accept", () => {
for (const mode of ["none", "ttl", "purge"] as const)
expect(edgeCacheHeaders("team_a", "/docs/x", mode, 60, undefined, true)).toEqual({
"Cache-Control": "private, no-store",
});
});

it("tags llms.txt with the sitemap and docs", () => {
expect(edgeTags("team_a", "/llms.txt")).toEqual([
"team:team_a",
"shell:team_a",
"sitemap:team_a",
"docs:team_a",
]);
});
});
8 changes: 8 additions & 0 deletions apps/sites/lib/edge-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ export function edgeTags(teamId: string, pathname: string): string[] {
if (path === "/changelog") tags.push(`page:${teamId}:changelog`);
} else if (path === "/sitemap.xml") {
tags.push(`sitemap:${teamId}`);
} else if (path === "/llms.txt") {
// Lists everything in the sitemap, plus the API reference.
tags.push(`sitemap:${teamId}`, `docs:${teamId}`);
} else if (
!["robots.txt", "favicon.ico", "opengraph-image", "embed"].includes(first)
) {
Expand Down Expand Up @@ -70,9 +73,14 @@ export function edgeCacheHeaders(
mode: EdgeCacheMode = edgeCacheMode(),
ttl = Number(process.env.EDGE_CACHE_TTL) || 60,
rsc = { rscHeader: false, rscParam: false },
/** Markdown chosen by `Accept`, at a URL that also serves HTML. Never
* stored, so a cache that ignores `Vary: Accept` can't hand Markdown to
* browsers. Agents can cache the `.md` URL instead. */
markdownByAccept = false,
): Record<string, string> | null {
if (isPrivatePath(pathname)) return { "Cache-Control": "private, no-store" };
if (isMixedRscRequest(rsc)) return { "Cache-Control": "private, no-store" };
if (markdownByAccept) return { "Cache-Control": "private, no-store" };
if (mode === "none") return null;
const maxAge = mode === "ttl" ? ttl : 31_536_000;
const headers: Record<string, string> = {
Expand Down
46 changes: 46 additions & 0 deletions apps/sites/lib/markdown-negotiation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import {
isNegotiablePath,
markdownSourcePath,
markdownUrl,
prefersMarkdown,
} from "./markdown-negotiation";

describe("prefersMarkdown", () => {
it("serves Markdown only when asked for at least as strongly as HTML", () => {
expect(prefersMarkdown("text/markdown")).toBe(true);
expect(prefersMarkdown("text/markdown, text/html;q=0.9")).toBe(true);
expect(prefersMarkdown("text/html, text/markdown")).toBe(true);
expect(prefersMarkdown("text/html, text/markdown;q=0.5")).toBe(false);
expect(prefersMarkdown("text/markdown;q=0")).toBe(false);
});

it("keeps browsers on HTML", () => {
expect(
prefersMarkdown(
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,*/*;q=0.8",
),
).toBe(false);
expect(prefersMarkdown("*/*")).toBe(false);
expect(prefersMarkdown(null)).toBe(false);
});
});

describe("markdown paths", () => {
it("maps .md URLs to their page", () => {
expect(markdownSourcePath("/docs/intro.md")).toBe("/docs/intro");
expect(markdownSourcePath("/index.md")).toBe("/");
expect(markdownSourcePath("/docs/intro")).toBeNull();
expect(markdownUrl("/")).toBe("/index.md");
expect(markdownUrl("/blog/post")).toBe("/blog/post.md");
});

it("negotiates pages only", () => {
expect(isNegotiablePath("/")).toBe(true);
expect(isNegotiablePath("/docs/intro")).toBe(true);
expect(isNegotiablePath("/sitemap.xml")).toBe(false);
expect(isNegotiablePath("/blog/rss.xml")).toBe(false);
expect(isNegotiablePath("/help/tickets/t_1")).toBe(false);
expect(isNegotiablePath("/embed/chat")).toBe(false);
});
});
46 changes: 46 additions & 0 deletions apps/sites/lib/markdown-negotiation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/**
* When a request gets Markdown instead of HTML (see lib/markdown.ts). Kept
* free of imports: the proxy runs it on every request.
*/

/** Route segment, under `/sites/[teamId]`, of the Markdown handler. */
export const MARKDOWN_ROUTE = "agent-markdown";

/** True when the Accept header ranks Markdown at least as high as HTML.
* Browsers never list `text/markdown`, so they keep getting HTML. */
export function prefersMarkdown(accept: string | null): boolean {
if (!accept) return false;
let markdown = 0;
let html = 0;
for (const part of accept.split(",")) {
const [type = "", ...params] = part.split(";").map((value) => value.trim());
const qParam = params.find((param) => param.startsWith("q="));
const q = qParam ? Number(qParam.slice(2)) : 1;
if (Number.isNaN(q)) continue;
const mediaType = type.toLowerCase();
if (mediaType === "text/markdown" || mediaType === "text/x-markdown")
markdown = Math.max(markdown, q);
else if (mediaType === "text/html") html = Math.max(html, q);
}
return markdown > 0 && markdown >= html;
}

/** Paths whose response depends on Accept: pages, not files or private
* routes. `.md` URLs, feeds and assets have one representation. */
export function isNegotiablePath(pathname: string): boolean {
if (/\.[a-z0-9]+$/i.test(pathname)) return false;
return !/^\/(_preview|embed|help\/tickets|opengraph-image)(\/|$)/.test(pathname);
}

/** `/docs/intro.md` → `/docs/intro`; `/index.md` → `/`. Null when the path
* isn't a `.md` URL. */
export function markdownSourcePath(pathname: string): string | null {
if (!pathname.endsWith(".md")) return null;
const path = pathname.slice(0, -".md".length);
return path === "/index" || path === "" ? "/" : path;
}

/** The `.md` URL of a public path, for links agents follow. */
export function markdownUrl(path: string): string {
return path === "/" ? "/index.md" : `${path.replace(/\/+$/, "")}.md`;
}
Loading
Loading