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
15 changes: 14 additions & 1 deletion pkg/gen/clidocs/clidocs.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import (
"path/filepath"
"sort"
"strings"
"unicode"
"unicode/utf8"

"github.com/spf13/cobra"
"github.com/spf13/pflag"
Expand Down Expand Up @@ -92,7 +94,7 @@ func overviewPage(root *cobra.Command, fams []*cobra.Command) string {
func familyPage(fam *cobra.Command, order int) string {
var b bytes.Buffer
title := "oap " + fam.Name()
b.WriteString(mdxutil.Frontmatter(title, section, group, order, mdxutil.FirstLine(fam.Short)))
b.WriteString(mdxutil.Frontmatter(title, section, group, order, familyDescription(fam)))
fmt.Fprintf(&b, "# %s\n\n", title)
if d := describe(fam); d != "" {
b.WriteString(d + "\n\n")
Expand All @@ -102,6 +104,17 @@ func familyPage(fam *cobra.Command, order int) string {
return b.String()
}

// familyDescription is a family page's meta description. cobra's Short alone
// ("Manage AgentClass resources") names neither the command nor what the page
// holds, so a search result built from it reads as a fragment.
func familyDescription(fam *cobra.Command) string {
short := strings.TrimSuffix(mdxutil.FirstLine(fam.Short), ".")
if r, size := utf8.DecodeRuneInString(short); size > 0 {
short = string(unicode.ToUpper(r)) + short[size:] + ". "
}
return fmt.Sprintf("oap %s: %sEvery subcommand, flag, and example, generated from the CLI.", fam.Name(), short)
}

// writeCommandTree renders each visible subcommand of cmd as a heading section
// (usage, description, flags, example), then recurses. level is the markdown
// heading level for the direct children (2 = ##), capped at 4.
Expand Down
24 changes: 24 additions & 0 deletions pkg/gen/clidocs/clidocs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"os"
"path/filepath"
"testing"
"unicode/utf8"

"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -42,6 +43,8 @@ func TestGenerate(t *testing.T) {
require.NoError(t, err)
fam := string(famB)
assert.Contains(t, fam, "title: 'oap agent'")
assert.Contains(t, fam, "description: 'oap agent: Manage agents. Every subcommand, flag, and example, generated from the CLI.'",
"description names the command and what the page holds")
assert.Contains(t, fam, "`oap agent run <agent-class>`", "usage heading is backtick-wrapped so <args> stay literal")
assert.Contains(t, fam, "--prompt string", "local flag rendered")
assert.Contains(t, fam, "oap agent run pirate", "example rendered")
Expand All @@ -51,3 +54,24 @@ func TestGenerate(t *testing.T) {
_, statErr := os.Stat(filepath.Join(dir, "oap-secret.mdx"))
assert.True(t, os.IsNotExist(statErr), "no page for a hidden family")
}

func TestFamilyDescription(t *testing.T) {
const tail = "Every subcommand, flag, and example, generated from the CLI."
cases := []struct {
name string
short string
want string
}{
{name: "lowercase: first letter capitalized", short: "verify the install", want: "oap x: Verify the install. " + tail},
{name: "trailing period: not doubled", short: "Manage agents.", want: "oap x: Manage agents. " + tail},
{name: "leading multi-byte rune: capitalized whole", short: "élan mode", want: "oap x: Élan mode. " + tail},
{name: "empty Short: command and tail only", short: "", want: "oap x: " + tail},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := familyDescription(&cobra.Command{Use: "x", Short: tc.short})
assert.Equal(t, tc.want, got)
assert.True(t, utf8.ValidString(got), "valid UTF-8")
})
}
}
2 changes: 1 addition & 1 deletion pkg/gen/crddocs/crddocs.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ func overviewPage(crds []crdInfo) string {

func crdPage(c crdInfo, order int) string {
var b bytes.Buffer
b.WriteString(mdxutil.Frontmatter(c.kind, section, group, order, mdxutil.FirstLine(c.desc)))
b.WriteString(mdxutil.Frontmatter(c.kind, section, group, order, mdxutil.Summary(c.desc)))
fmt.Fprintf(&b, "# %s\n\n", c.kind)
fmt.Fprintf(&b, "**Group** `%s` · **Scope** %s", c.apiGroup, c.scope)
if len(c.shortNames) > 0 {
Expand Down
35 changes: 35 additions & 0 deletions pkg/gen/mdxutil/mdxutil.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package mdxutil
import (
"fmt"
"strings"
"unicode/utf8"
)

// Frontmatter renders the `export const meta = {…}` block every generated doc
Expand Down Expand Up @@ -44,6 +45,40 @@ func FirstLine(s string) string {
return strings.TrimSpace(s)
}

// summaryMax is the longest Summary returns: about what a search result shows
// of a meta description before it truncates.
const summaryMax = 160

// Summary turns a doc comment into a page's meta description: the first
// sentence of its first paragraph, with the comment's hard line wraps joined.
// FirstLine is wrong for this: Go doc comments wrap at ~80 columns, so their
// first line stops mid-sentence. A sentence over summaryMax is cut at a word
// boundary and ends in an ellipsis.
func Summary(s string) string {
para, _, _ := strings.Cut(strings.TrimSpace(s), "\n\n")
para = strings.Join(strings.Fields(para), " ")
for i := 0; i+2 < len(para); i++ {
// A sentence ends at ". " before a capital, which skips "e.g. x".
if para[i] == '.' && para[i+1] == ' ' && 'A' <= para[i+2] && para[i+2] <= 'Z' {
para = para[:i+1]
break
}
}
if len(para) <= summaryMax {
return para
}
cut := strings.LastIndexByte(para[:summaryMax], ' ')
if cut <= 0 {
// No space to break at: cut at the limit, backed off to a rune
// boundary so a multi-byte character is never split.
cut = summaryMax
for cut > 0 && !utf8.RuneStart(para[cut]) {
cut--
}
}
return strings.TrimRight(para[:cut], " ,;:-") + "…"
}

// TableText makes a string safe for a single markdown table cell: first line
// only, MDX-escaped, with the cell separator escaped.
func TableText(s string) string {
Expand Down
58 changes: 58 additions & 0 deletions pkg/gen/mdxutil/mdxutil_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package mdxutil

import (
"strings"
"testing"
"unicode/utf8"

"github.com/stretchr/testify/assert"
)

func TestSummary(t *testing.T) {
long := strings.Repeat("word ", 50) + "end."
cases := []struct {
name string
in string
want string
}{
{
name: "wrapped first sentence: joined, stops at the period",
in: "AgentClass is the template: model, system\nprompt, tools. It never runs\non its own.",
want: "AgentClass is the template: model, system prompt, tools.",
},
{
name: "single sentence: returned whole",
in: "SkillSource is a git repo that yields one or more Skills.",
want: "SkillSource is a git repo that yields one or more Skills.",
},
{
name: "second paragraph: ignored",
in: "Widget is a demo\n\nNamespaced. Reconciled elsewhere.",
want: "Widget is a demo",
},
{
name: "abbreviation before lowercase: not a sentence end",
in: "Channel binds a transport, e.g. a Slack channel. More here.",
want: "Channel binds a transport, e.g. a Slack channel.",
},
{
name: "over the limit: cut at a word boundary with an ellipsis",
in: long,
want: strings.TrimSpace(strings.Repeat("word ", 32)) + "…",
},
{
// 159 ASCII bytes, then a 3-byte rune straddling the 160-byte limit.
name: "no space and a rune across the limit: cut before the rune, valid UTF-8",
in: strings.Repeat("a", 159) + "日本語",
want: strings.Repeat("a", 159) + "…",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := Summary(tc.in)
assert.True(t, utf8.ValidString(got), "valid UTF-8")
assert.Equal(t, tc.want, got)
assert.LessOrEqual(t, len(strings.TrimSuffix(got, "…")), summaryMax)
})
}
}
7 changes: 7 additions & 0 deletions site/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,13 @@ Capture stills and clips with the `showcase/` engine (see
`site/public/media/`, add an entry to `site/content/_manifest.json`, and
reference it from a page with `<Clip name="…" />` or `<Screenshot name="…" />`.

### Add an MDX component

`lib/markdown.ts` turns guides into the Markdown served to agents
(`/docs/<slug>.md`, `/llms.txt`, `/llms-full.txt`). A new component or HTML tag
needs a rule there, or `lib/markdown.test.ts` fails on the first guide that uses
it.

## Rules (non-negotiable)

- **Real-not-drift.** Prefer captured fixtures and generated reference over
Expand Down
23 changes: 23 additions & 0 deletions site/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,29 @@ so treat it as possibly stale until you rebuild.
build, the generated `_pagefind/` search index
- `scripts/` — `check.ts`, the validator behind `pnpm check`

## Search engines and agents

- **Metadata.** Site-wide defaults are in `lib/site.ts` and `app/layout.tsx`;
each guide's title and `description` become its `<title>`, meta description,
and Open Graph tags. `pnpm check` fails a guide with no description.
- **Share cards.** `app/opengraph-image.tsx` is the site card;
`app/docs/[slug]/opengraph-image.tsx` renders one per guide. Both use
`components/OgCard.tsx`.
- **Structured data.** JSON-LD from `lib/structured-data.ts`: Organization,
WebSite and SoftwareSourceCode on every page, plus TechArticle and
BreadcrumbList on guides.
- **Sitemap and robots.** `app/sitemap.ts` and `app/robots.ts`.
- **Markdown for agents.** Every guide is also served as Markdown at
`/docs/<slug>.md`, and at `/docs/<slug>` for a request sending
`Accept: text/markdown` (rewrites in `next.config.mjs` to `app/md/[slug]`).
`/llms.txt` indexes them; `/llms-full.txt` is every guide in one file. The
MDX-to-Markdown transform is `lib/markdown.ts`; its test fails if a guide uses
a component the transform does not translate.
- **Copy page.** `components/CopyPage.tsx` sits beside each guide's title: it
copies the page's Markdown to the clipboard, views it, or opens ChatGPT or
Claude prefilled with a prompt to read it (the URLs are in
`lib/copy-page.ts`).

## Analytics

The site records page views and page leaves through PostHog's cookieless mode,
Expand Down
8 changes: 3 additions & 5 deletions site/app/(landing)/Landing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,11 @@ import { OapMark, OAP_MARK_PATHS } from "@/components/OapMark";
import { SiteFooter } from "@/components/SiteFooter";
import { THEME_MODES, type ThemeMode } from "@/components/ThemeToggle";
import { DiscordIcon, GitHubIcon } from "@/components/BrandIcons";
import { DISCORD_URL } from "@/lib/site";
import { DISCORD_URL, REPO_URL } from "@/lib/site";
import { getAsset } from "@/lib/manifest";
import { GeoFigure } from "./geo/GeoFigure";
import { OWASP, COVERAGE_LABEL, coverageSummary } from "./owasp";

const REPO = "https://github.com/authzed/openagentprimitives";

/* The page's copy follows the repository README: its order, its emphasis and
* its claims. When the README's story changes, change this page to match. */

Expand Down Expand Up @@ -1143,7 +1141,7 @@ export function Landing() {
</a>
<a href="#secure">Security</a>
<a href="#agent-builder">Agent Builder</a>
<a className="lp-nav-icon" href={REPO} aria-label="GitHub">
<a className="lp-nav-icon" href={REPO_URL} aria-label="GitHub">
<GitHubIcon />
</a>
<a className="lp-nav-icon" href={DISCORD_URL} aria-label="Discord">
Expand Down Expand Up @@ -1443,7 +1441,7 @@ export function Landing() {
<a className="lp-btn lp-btn--ghost" href="/docs/what-is-oap">
Read the docs
</a>
<a className="lp-btn lp-btn--ghost" href={REPO}>
<a className="lp-btn lp-btn--ghost" href={REPO_URL}>
Source
</a>
</div>
Expand Down
20 changes: 13 additions & 7 deletions site/app/(landing)/page.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
import type { Metadata } from "next";
import { OG_IMAGE, SITE_DESCRIPTION, SITE_NAME } from "@/lib/site";
import { JsonLd } from "@/components/JsonLd";
import { OG_IMAGE, SITE_DESCRIPTION, SITE_NAME, SITE_TITLE } from "@/lib/site";
import { landingGraph } from "@/lib/structured-data";
import { Landing } from "./Landing";
import "./landing.css";

export const metadata: Metadata = {
title: {
absolute: "Open Agent Primitives: a secure way to run enterprise AI agents",
},
title: { absolute: SITE_TITLE },
description: SITE_DESCRIPTION,
alternates: { canonical: "/" },
// The docs index for LLMs and agents (app/llms.txt).
alternates: { canonical: "/", types: { "text/plain": "/llms.txt" } },
openGraph: {
title: SITE_NAME,
title: SITE_TITLE,
description: SITE_DESCRIPTION,
url: "/",
siteName: SITE_NAME,
Expand All @@ -21,5 +22,10 @@ export const metadata: Metadata = {
};

export default function Page() {
return <Landing />;
return (
<>
<JsonLd data={landingGraph()} />
<Landing />
</>
);
}
66 changes: 66 additions & 0 deletions site/app/docs/[slug]/opengraph-image.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { ImageResponse } from "next/og";
import { nbWrap, OG_INK, OG_SIZE, OgCard } from "@/components/OgCard";
import { guideSlugs, loadGuide } from "@/lib/guides";
import { plainText } from "@/lib/structured-data";

// One share card per guide, so a docs link pasted into Slack or a search
// result shows which page it is, not the site-wide card. Prerendered for
// every guide. The alt is static: Next 16 hands generateImageMetadata no
// params under a dynamic segment at build time, so it cannot name the page.
export const size = OG_SIZE;
export const contentType = "image/png";
export const alt = "An Open Agent Primitives docs page title and summary";

// Like the page and its Markdown route: an unknown slug is a 404, not a
// failed import and a 500.
export const dynamicParams = false;

export async function generateStaticParams() {
return (await guideSlugs()).map((slug) => ({ slug }));
}

// Two lines of description at this size; longer ones end in an ellipsis.
const DESC_MAX = 150;

export default async function GuideOgImage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { guide } = await loadGuide((await params).slug);
const kicker = guide.group
? `${guide.section} · ${guide.group}`
: guide.section;
let desc = plainText(guide.description ?? "");
if (desc.length > DESC_MAX)
desc = desc.slice(0, desc.lastIndexOf(" ", DESC_MAX)) + "…";
return new ImageResponse(
<OgCard footer="Open Agent Primitives docs · openap.org">
<div style={{ fontSize: 28, color: OG_INK.accent, marginBottom: 20 }}>
{nbWrap(kicker)}
</div>
<div
style={{
fontSize: guide.title.length > 28 ? 60 : 72,
lineHeight: 1.08,
letterSpacing: "-0.03em",
}}
>
{nbWrap(guide.title)}
</div>
{desc && (
<div
style={{
fontSize: 30,
lineHeight: 1.35,
color: OG_INK.muted,
marginTop: 24,
}}
>
{nbWrap(desc)}
</div>
)}
</OgCard>,
size,
);
}
Loading
Loading