import type { Metadata } from "next";
import { allSpecs } from "./specs";
/**
* Every page's title, description, canonical URL and social card, from one
* builder.
*
* Why this exists: pages exported `{ title, description, alternates }` and
* nothing else. Next.js shallow-merges metadata, so a page that never declares
* `openGraph` inherits the root layout's object wholesale -- which meant
* og:title, og:description and og:url were the HOMEPAGE's on every page of the
* site. Reddit, Slack, X and Facebook read og:title, so every spec we posted
* was announced as "LogicSRC - Open Coordination Standards for Humans & AI
* Agents" no matter which spec the link pointed at. The
tag was correct
* the whole time, which is why looking at the page never caught it.
*
* So no page declares raw metadata any more. Everything goes through
* `specMetadata` (a spec landing page, titled from lib/specs.ts),
* `pageMetadata` (the site's own pages, titled from PAGE_META below) or
* `contentMetadata` (a doc, post, skill or ontology record, titled from the
* content), and all three always emit openGraph and twitter titles.
*/
// `||`, not `??`: the dev2 image build declares PUBLIC_URL as an ARG, so a service
// that never sets it (app.logicsrc.com builds this workspace too) sees "" and
// `new URL("")` in layout.tsx failed the whole build.
export const SITE_URL = (process.env.PUBLIC_URL || "https://logicsrc.com").replace(/\/$/, "");
export const SITE_NAME = "LogicSRC";
/**
* The branded card from app/opengraph-image.tsx, named explicitly.
*
* Next.js only falls back to the file-convention image for routes that declare
* no `openGraph`. Every page declares one now, and blog posts already did --
* which is why live posts shipped with no og:image whatsoever. So the default
* image is stated here rather than inherited.
*/
export const SITE_OG_IMAGE = `${SITE_URL}/opengraph-image`;
/** Long enough to say something, short enough to survive a link preview. */
export const TITLE_MAX = 100;
export type PageMeta = { title: string; description: string };
/**
* The site's own pages -- the ones that are not a spec and not content.
* Keyed by path, so `contract/page-metadata.contract.test.ts` can require an
* entry for every static route in sitemap.ts: a new page reaching the sitemap
* with no title here fails the test instead of silently shipping the
* homepage's social card.
*/
export const PAGE_META: Record = {
"/": {
title: "LogicSRC — Open Coordination Standards for Humans & AI Agents",
description:
"Open schemas, primitives, and conventions for coordination between humans, AI agents, plugins, payment systems, and hosted products.",
},
"/specs": {
title: "LogicSRC Specs: Four Families of Open Coordination Standards",
description:
"Every LogicSRC specification in four families: people and agents, access and credentials, catalogs a site serves about itself, and agents and process. Each family drills down to its specs, each spec to an overview and the specification text.",
},
"/docs": {
title: "LogicSRC Docs: Specification Text, CLI, Config, and Plugins",
description:
"The specification text of every LogicSRC spec, grouped by family, and the guides: data model, CLI and TUI conventions, config, permissions, plugins.",
},
"/openontology/explore": {
title: "Explore OpenOntology: Entities, Claims, and Saved Queries",
description:
"A read-only explorer over the OpenOntology example package: entity types, entities, claims with provenance, and the saved queries that answer real questions.",
},
"/hire-us": {
title: "Hire Us: Implementation Help for LogicSRC Standards",
description:
"Implementation help for LogicSRC, OpenFleet, and Credential Sharing at $400/hour/agent for accepted work, paid via CoinPay.",
},
"/pricing": {
title: "Pricing: Free Standard, $400 per Agent-Hour Implementation",
description:
"LogicSRC the open specification, schemas, SDKs, and CLI are free and open source. Implementation help is $400/hour/agent for accepted work, paid via CoinPay.",
},
"/blog": {
title: "Blog: LogicSRC Project Notes and Release Posts",
description:
"Project notes for LogicSRC OpenSpec standards, AgentSwarm, AgentByte, SDKs, MCP, and reference implementations.",
},
"/about": {
title: "About LogicSRC: The Profullstack Open-Specification Project",
description:
"LogicSRC is the Profullstack, Inc. open-specification project for coordination between humans and AI agents — schemas, primitives, and conventions that products implement without owning the standard.",
},
"/terms": {
title: "Terms of Engagement",
description:
"Terms of engagement for LogicSRC: the specification and tooling are open source and free; Profullstack implementation work is billed at $400/hour/agent against approved hours, with a 10-agent-hour minimum.",
},
"/privacy": {
title: "Privacy: What logicsrc.com Collects",
description:
"What logicsrc.com collects and what it does not: privacy-friendly analytics, the Hire Us project form, the CoinPay sign-in cookie, and the boundary that keeps credential values off our servers.",
},
};
/** The site suffix, skipped when the title already names the site. */
export function siteTitle(title: string): string {
return title.includes(SITE_NAME) ? title : `${title} · ${SITE_NAME}`;
}
/**
* ": ", trimmed at a clause boundary to fit TITLE_MAX.
*
* The registry's `line` is a full sentence written for a list, and some run
* well past what a link preview shows. Cutting at a comma or a colon keeps the
* first clause, which is the part that identifies the spec.
*/
const DANGLING = new Set([
"a", "an", "and", "as", "at", "by", "for", "from", "in", "of", "on", "or",
"so", "that", "the", "to", "under", "with",
]);
export function composeTitle(name: string, line: string): string {
const room = TITLE_MAX - ` · ${SITE_NAME}`.length - `${name}: `.length;
if (line.length <= room) return `${name}: ${line}`;
const cut = line.slice(0, room);
const boundary = Math.max(cut.lastIndexOf(", "), cut.lastIndexOf(": "), cut.lastIndexOf(" ("));
let clause = boundary > 24 ? cut.slice(0, boundary) : cut.slice(0, cut.lastIndexOf(" "));
// A cut at a word boundary can leave the sentence hanging on a preposition
// ("...the way in and the way out of"), which reads worse than stopping one
// word earlier.
for (;;) {
clause = clause.replace(/[\s,;:(]+$/, "");
const words = clause.split(" ");
if (words.length > 3 && DANGLING.has(words[words.length - 1].toLowerCase())) {
clause = words.slice(0, -1).join(" ");
continue;
}
break;
}
return `${name}: ${clause}`;
}
function absolute(path: string): string {
if (/^https?:\/\//.test(path)) return path;
return path === "/" ? SITE_URL : `${SITE_URL}${path.startsWith("/") ? path : `/${path}`}`;
}
type BuildInput = PageMeta & {
/** Route path, used for og:url and (unless `canonical` overrides it) rel=canonical. */
path: string;
/** Absolute canonical for syndicated content whose original lives elsewhere. */
canonical?: string;
type?: "website" | "article";
images?: string[];
/** Extra alternates, e.g. the blog's RSS feed. */
alternateTypes?: Record;
};
/**
* The one place page metadata is assembled. openGraph and twitter are never
* omitted, because omitting them inherits the layout's.
*/
export function buildMetadata(input: BuildInput): Metadata {
const title = siteTitle(input.title);
const canonical = input.canonical ?? input.path;
const images = input.images?.length ? input.images : [SITE_OG_IMAGE];
return {
title,
description: input.description,
alternates: {
canonical,
...(input.alternateTypes ? { types: input.alternateTypes } : {}),
},
openGraph: {
type: input.type ?? "website",
siteName: SITE_NAME,
url: absolute(canonical),
title,
description: input.description,
images,
},
twitter: {
card: "summary_large_image",
title,
description: input.description,
images,
},
};
}
/** Metadata for one of the site's own pages, from its entry in `PAGE_META`. */
export function pageMetadata(path: string, overrides: Partial = {}): Metadata {
const meta = PAGE_META[path];
if (!meta) {
throw new Error(
`No PAGE_META entry for "${path}". Add one so the page gets its own social title.`
);
}
return buildMetadata({ ...meta, path, ...overrides });
}
/**
* Metadata for a spec landing page.
*
* The name and the one-line subject come from lib/specs.ts, so a spec's title
* cannot drift from the way it is described everywhere else on the site, and a
* new spec page gets a real title the moment its registry entry exists. The
* longer marketing description stays on the page that wrote it.
*/
export function specMetadata(path: string, description?: string): Metadata {
const slug = path.replace(/^\//, "");
const spec = allSpecs().find((s) => s.slug === slug || s.landing === path);
if (!spec) {
throw new Error(
`No spec in lib/specs.ts for "${path}". Add its registry entry so the page is titled and listed.`
);
}
return buildMetadata({
title: composeTitle(spec.name, spec.line),
description: description ?? spec.line,
path,
});
}
/** The name and subject line a spec is titled with, for tests and listings. */
export function specTitle(slug: string): string | null {
const spec = allSpecs().find((s) => s.slug === slug);
return spec ? composeTitle(spec.name, spec.line) : null;
}
/** Metadata for a route whose title comes from content (a doc, post, skill, record). */
export function contentMetadata(input: BuildInput): Metadata {
return buildMetadata(input);
}
/** The 404 case, so a missing slug still gets its own title rather than the site's. */
export function notFoundMetadata(what: string): Metadata {
return {
title: `Not found · ${SITE_NAME}`,
description: `This ${what} does not exist on ${SITE_NAME}.`,
robots: { index: false, follow: true },
};
}