mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-10-02 20:57:03 +00:00
Reddit, Slack, X and Facebook read og:title, and every page of
logicsrc.com was serving the homepage's. The <title> tag was correct per
page, which is why it went unnoticed: Next.js shallow-merges metadata, so
a page exporting only `{ title, description, alternates }` inherits the
root layout's `openGraph` object whole. og:title, og:description and
og:url were the homepage's on all 45 spec landing pages, every doc, every
report and every SPA section.
Blog posts were the one route that declared openGraph, and they proved
the mechanism: their og:title is right. They also showed the other half
of it -- declaring openGraph drops the file-convention opengraph-image,
so live posts shipped with no og:image at all, and their twitter:title
was still the homepage's.
- src/lib/page-meta.ts: one builder, three entry points. specMetadata
titles a spec landing page from its lib/specs.ts entry, pageMetadata
titles the site's own pages from PAGE_META, contentMetadata titles a
doc, post, skill or ontology record from its content. All three always
emit openGraph and twitter title, description, url and image.
- Spec titles come from the registry, so they cannot drift from the way
the sidebar, /specs and the home page describe the same spec, and a new
spec page is titled the moment its registry entry exists. composeTitle
trims a long subject line at a clause boundary to fit a link preview.
- Doc pages carry that subject line too, because every spec's H1 is just
its name: "OpenStream" alone was the whole title.
- docExcerpt joins the wrapped lines of the first real paragraph and
skips Status:/Slug: headers and list lead-ins, so descriptions stopped
being "Status: 0.1 draft" and "Core tables:".
- contract/page-metadata.contract.test.ts fails if a sitemap route has no
title, if a spec page has no registry entry, if two pages share a
title, if a page declares metadata without the builder, or if a page
loses its social image.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
297 lines
12 KiB
TypeScript
297 lines
12 KiB
TypeScript
import { readdirSync, readFileSync } from "node:fs";
|
|
import { resolve } from "node:path";
|
|
import { describe, expect, it } from "vitest";
|
|
import { DOC_SLUGS, docExcerpt, docSubtitle, docTitle, readDoc } from "../src/lib/docs";
|
|
import {
|
|
buildMetadata,
|
|
composeTitle,
|
|
PAGE_META,
|
|
pageMetadata,
|
|
SITE_NAME,
|
|
SITE_OG_IMAGE,
|
|
siteTitle,
|
|
specMetadata,
|
|
TITLE_MAX,
|
|
} from "../src/lib/page-meta";
|
|
import { allSpecs } from "../src/lib/specs";
|
|
|
|
/**
|
|
* Guards every page's own title.
|
|
*
|
|
* Pages exported `{ title, description, alternates }` and nothing else.
|
|
* Next.js shallow-merges metadata, so a page without an `openGraph` block
|
|
* inherited the root layout's -- meaning 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" regardless of which spec the
|
|
* link pointed at. The <title> tag was correct throughout, which is why
|
|
* looking at the page never caught it.
|
|
*
|
|
* These tests fail if a page stops carrying its own social title, if a new
|
|
* route reaches the sitemap without one, or if two pages share a title.
|
|
*/
|
|
|
|
const APP_DIR = resolve(process.cwd(), "src/app");
|
|
|
|
type Meta = ReturnType<typeof pageMetadata>;
|
|
|
|
function titleOf(metadata: Meta): string {
|
|
return String(metadata.title);
|
|
}
|
|
|
|
function ogOf(metadata: Meta): { title?: unknown; description?: unknown; url?: unknown; images?: unknown } {
|
|
return (metadata.openGraph ?? {}) as {
|
|
title?: unknown;
|
|
description?: unknown;
|
|
url?: unknown;
|
|
images?: unknown;
|
|
};
|
|
}
|
|
|
|
function twitterOf(metadata: Meta): { title?: unknown; images?: unknown } {
|
|
return (metadata.twitter ?? {}) as { title?: unknown; images?: unknown };
|
|
}
|
|
|
|
/** The sitemap's own static routes, read from source so the two cannot drift. */
|
|
function sitemapStaticPaths(): string[] {
|
|
const source = readFileSync(resolve(APP_DIR, "sitemap.ts"), "utf8");
|
|
// The array literal, not its type: the Route type mentions
|
|
// `Sitemap[number]["changeFrequency"]`, so a naive search for "];" stops
|
|
// inside it and the block comes back empty.
|
|
const start = source.indexOf("= [", source.indexOf("const STATIC_ROUTES"));
|
|
const end = source.indexOf("\n];", start);
|
|
return [...source.slice(start, end).matchAll(/path:\s*"([^"]+)"/g)].map((m) => m[1]);
|
|
}
|
|
|
|
describe("the site's own pages each have a title", () => {
|
|
const sitemapPaths = sitemapStaticPaths();
|
|
|
|
it("reads the sitemap's static routes", () => {
|
|
expect(sitemapPaths.length).toBeGreaterThan(5);
|
|
expect(sitemapPaths).toContain("/");
|
|
});
|
|
|
|
it("every static route in the sitemap has a PAGE_META entry", () => {
|
|
const missing = sitemapPaths.filter((path) => !PAGE_META[path]);
|
|
expect(
|
|
missing,
|
|
`Add these paths to PAGE_META in src/lib/page-meta.ts, or they ship with the homepage's social card: ${missing.join(", ")}`
|
|
).toEqual([]);
|
|
});
|
|
|
|
it("no PAGE_META entry is for a path nothing serves", () => {
|
|
// /hire-us is an SPA section rather than a sitemap-listed page in its own
|
|
// right, so it is allowed to appear here without a sitemap entry.
|
|
const allowed = new Set([...sitemapPaths, "/hire-us"]);
|
|
const orphans = Object.keys(PAGE_META).filter((path) => !allowed.has(path));
|
|
expect(orphans, `These PAGE_META paths are not served: ${orphans.join(", ")}`).toEqual([]);
|
|
});
|
|
|
|
it("titles and descriptions are unique per page", () => {
|
|
const seen = new Map<string, string>();
|
|
for (const [path, meta] of Object.entries(PAGE_META)) {
|
|
const title = siteTitle(meta.title);
|
|
const earlier = seen.get(title);
|
|
expect(earlier, `"${title}" is used by both ${earlier} and ${path}`).toBeUndefined();
|
|
seen.set(title, path);
|
|
}
|
|
const descriptions = Object.values(PAGE_META).map((m) => m.description);
|
|
expect(new Set(descriptions).size).toBe(descriptions.length);
|
|
});
|
|
|
|
it("only the homepage is titled with the homepage title", () => {
|
|
const home = siteTitle(PAGE_META["/"].title);
|
|
const sharing = Object.entries(PAGE_META)
|
|
.filter(([path, meta]) => path !== "/" && siteTitle(meta.title) === home)
|
|
.map(([path]) => path);
|
|
expect(sharing).toEqual([]);
|
|
});
|
|
|
|
it("every title names the site, says something, and fits a link preview", () => {
|
|
for (const [path, meta] of Object.entries(PAGE_META)) {
|
|
const title = siteTitle(meta.title);
|
|
expect(title, path).toContain(SITE_NAME);
|
|
// "Docs · LogicSRC" and "Blog · LogicSRC" are what this change replaced:
|
|
// technically distinct, useless as a headline. Require a real subject.
|
|
expect(title.replace(` · ${SITE_NAME}`, "").trim().split(/\s+/).length, path).toBeGreaterThan(1);
|
|
expect(title.length, `${path} title is ${title.length} chars`).toBeLessThanOrEqual(TITLE_MAX);
|
|
expect(meta.description.length, path).toBeGreaterThan(40);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("spec landing pages are titled from the registry", () => {
|
|
const landings = allSpecs().filter((spec) => spec.landing);
|
|
|
|
it("there are spec landing pages to check", () => {
|
|
expect(landings.length).toBeGreaterThan(20);
|
|
});
|
|
|
|
it("each one gets its own title, description, og:url and image", () => {
|
|
const titles = new Map<string, string>();
|
|
for (const spec of landings) {
|
|
const path = spec.landing as string;
|
|
const metadata = specMetadata(path);
|
|
const title = titleOf(metadata);
|
|
|
|
expect(title, path).toContain(spec.name);
|
|
expect(title.length, `${path} title is ${title.length} chars: ${title}`).toBeLessThanOrEqual(TITLE_MAX);
|
|
const earlier = titles.get(title);
|
|
expect(earlier, `"${title}" is used by both ${earlier} and ${path}`).toBeUndefined();
|
|
titles.set(title, path);
|
|
|
|
expect(ogOf(metadata).title, path).toBe(title);
|
|
expect(twitterOf(metadata).title, path).toBe(title);
|
|
expect(ogOf(metadata).url, path).toBe(`https://logicsrc.com${path}`);
|
|
expect((metadata.alternates as { canonical?: unknown }).canonical, path).toBe(path);
|
|
expect(ogOf(metadata).images, path).toEqual([SITE_OG_IMAGE]);
|
|
}
|
|
});
|
|
|
|
it("no spec landing title is the homepage's", () => {
|
|
const home = siteTitle(PAGE_META["/"].title);
|
|
for (const spec of landings) {
|
|
expect(titleOf(specMetadata(spec.landing as string)), spec.slug).not.toBe(home);
|
|
}
|
|
});
|
|
|
|
it("a page's own description is kept when it passes one", () => {
|
|
const spec = landings[0];
|
|
const metadata = specMetadata(spec.landing as string, "A longer description written on the page.");
|
|
expect(metadata.description).toBe("A longer description written on the page.");
|
|
expect(ogOf(metadata).description).toBe("A longer description written on the page.");
|
|
});
|
|
|
|
it("a spec page with no registry entry fails loudly rather than falling back", () => {
|
|
expect(() => specMetadata("/not-a-spec")).toThrow(/lib\/specs\.ts/);
|
|
expect(() => pageMetadata("/not-a-page")).toThrow(/PAGE_META/);
|
|
});
|
|
|
|
it("composeTitle trims a long subject line at a clause boundary", () => {
|
|
const long = composeTitle(
|
|
"OpenObject",
|
|
"A bucket you can mount: keyed objects encrypted by their owner, kept on paid disks at a stated redundancy, verified, repaired and read back by path"
|
|
);
|
|
expect(siteTitle(long).length).toBeLessThanOrEqual(TITLE_MAX);
|
|
expect(long.startsWith("OpenObject: A bucket you can mount")).toBe(true);
|
|
expect(long.endsWith(",")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("content routes carry their own social title too", () => {
|
|
it("a doc, report or post gets openGraph and twitter titles and an image", () => {
|
|
const metadata = buildMetadata({
|
|
title: "OpenStream: A lossless byte-stream relay envelope",
|
|
description: "How one program hands another the exact bytes of a stream.",
|
|
path: "/docs/openstream",
|
|
type: "article",
|
|
});
|
|
expect(titleOf(metadata)).toBe(
|
|
"OpenStream: A lossless byte-stream relay envelope · LogicSRC"
|
|
);
|
|
expect(ogOf(metadata).title).toBe(titleOf(metadata));
|
|
expect(twitterOf(metadata).title).toBe(titleOf(metadata));
|
|
expect(ogOf(metadata).url).toBe("https://logicsrc.com/docs/openstream");
|
|
expect(ogOf(metadata).images).toEqual([SITE_OG_IMAGE]);
|
|
});
|
|
|
|
/**
|
|
* Next.js only supplies the file-convention app/opengraph-image.tsx card to
|
|
* routes that declare no `openGraph`. Every route declares one now, so the
|
|
* card has to be named explicitly or every page loses its social image --
|
|
* which is what already happened to blog posts, the one route that used to
|
|
* declare openGraph. They shipped with no og:image at all.
|
|
*/
|
|
it("a post with a featured image uses it instead of the site card", () => {
|
|
const metadata = buildMetadata({
|
|
title: "A post with art",
|
|
description: "It has its own image.",
|
|
path: "/blog/art",
|
|
type: "article",
|
|
images: ["https://cdn.example.com/art.png"],
|
|
});
|
|
expect(ogOf(metadata).images).toEqual(["https://cdn.example.com/art.png"]);
|
|
});
|
|
|
|
it("a syndicated post points og:url at its canonical original", () => {
|
|
const metadata = buildMetadata({
|
|
title: "A guest post",
|
|
description: "Published first somewhere else.",
|
|
path: "/blog/guest",
|
|
canonical: "https://example.com/guest",
|
|
type: "article",
|
|
});
|
|
expect(ogOf(metadata).url).toBe("https://example.com/guest");
|
|
});
|
|
});
|
|
|
|
describe("no page declares raw metadata", () => {
|
|
function pageFiles(dir: string, out: string[] = []): string[] {
|
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
const path = resolve(dir, entry.name);
|
|
if (entry.isDirectory()) pageFiles(path, out);
|
|
else if (entry.name === "page.tsx" || entry.name === "layout.tsx") out.push(path);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
it("every page and layout builds its metadata through lib/page-meta", () => {
|
|
const offenders: string[] = [];
|
|
for (const file of pageFiles(APP_DIR)) {
|
|
const source = readFileSync(file, "utf8");
|
|
const declares = /export\s+(const\s+metadata|async\s+function\s+generateMetadata)/.test(source);
|
|
if (!declares) continue;
|
|
if (!source.includes('from "@/lib/page-meta"')) offenders.push(file.replace(`${APP_DIR}/`, ""));
|
|
}
|
|
expect(
|
|
offenders,
|
|
`These routes declare metadata without lib/page-meta, so they inherit the layout's openGraph title: ${offenders.join(", ")}`
|
|
).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("doc pages are titled by their subject, not just their name", () => {
|
|
it("every published doc has a subject line", () => {
|
|
const missing = DOC_SLUGS.filter((slug) => docSubtitle(slug).length < 10);
|
|
expect(
|
|
missing,
|
|
`These docs have no subject line: give the spec a registry entry in lib/specs.ts, or the guide an entry in GUIDE_SUBTITLES: ${missing.join(", ")}`
|
|
).toEqual([]);
|
|
});
|
|
|
|
it("doc titles are distinct, name the doc, and fit a link preview", () => {
|
|
const titles = new Map<string, string>();
|
|
for (const slug of DOC_SLUGS) {
|
|
const md = readDoc(slug);
|
|
expect(md, `docs/${slug}.md should be readable`).toBeTruthy();
|
|
const name = docTitle(md as string, slug);
|
|
const title = siteTitle(composeTitle(name, docSubtitle(slug)));
|
|
const earlier = titles.get(title);
|
|
expect(earlier, `"${title}" is used by both ${earlier} and ${slug}`).toBeUndefined();
|
|
titles.set(title, slug);
|
|
expect(title).toContain(name);
|
|
expect(title.length, `${slug} title is ${title.length} chars: ${title}`).toBeLessThanOrEqual(TITLE_MAX);
|
|
}
|
|
});
|
|
|
|
it("no doc description is a Status:/Slug: header or a bare list lead-in", () => {
|
|
for (const slug of DOC_SLUGS) {
|
|
const excerpt = docExcerpt(readDoc(slug) as string);
|
|
expect(excerpt, slug).not.toMatch(/^(status|slug|version)\b\s*:/i);
|
|
expect(excerpt, slug).not.toMatch(/:$/);
|
|
// Either a real paragraph, or nothing -- in which case the page and the
|
|
// docs index both fall back to the doc's subject line.
|
|
if (excerpt) expect(excerpt.length, slug).toBeGreaterThan(40);
|
|
}
|
|
});
|
|
|
|
it("every doc has a usable description one way or the other", () => {
|
|
for (const slug of DOC_SLUGS) {
|
|
const md = readDoc(slug) as string;
|
|
const name = docTitle(md, slug);
|
|
const description =
|
|
docExcerpt(md) || `${name}: ${docSubtitle(slug)}. Part of the LogicSRC open-standards surface.`;
|
|
expect(description.length, slug).toBeGreaterThan(40);
|
|
}
|
|
});
|
|
});
|