mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-10-02 04:43:58 +00:00
The dev2 image build declares PUBLIC_URL as a Dockerfile ARG, so the
app.logicsrc.com service (which builds this workspace too, from the same
root Dockerfile) sees PUBLIC_URL="" rather than unset. `??` keeps the
empty string and `new URL("")` in layout.tsx failed the build at
"Collecting page data" (run 36161007683). `||` treats empty as absent.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
243 lines
10 KiB
TypeScript
243 lines
10 KiB
TypeScript
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 <title> 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<string, PageMeta> = {
|
|
"/": {
|
|
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}`;
|
|
}
|
|
|
|
/**
|
|
* "<Name>: <what it is>", 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<string, string>;
|
|
};
|
|
|
|
/**
|
|
* 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<BuildInput> = {}): 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 },
|
|
};
|
|
}
|