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
71 changes: 70 additions & 1 deletion apps/docs/app/[lang]/blog/[[...slug]]/page.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,14 @@ import { getMDXComponents } from '@/mdx-components';
import { HomeLayout } from 'fumadocs-ui/layouts/home';
import { baseOptions } from '@/lib/layout.shared';
import { absoluteUrl } from '@/lib/site';
import {
compact,
JsonLd,
type JsonLdNode,
ORGANIZATION,
ORGANIZATION_REF,
sitemapLastModified,
} from '@/lib/structured-data';
import Link from 'next/link';
import { ArrowLeft } from 'lucide-react';

Expand All@@ -20,6 +28,66 @@ interface BlogPostData {

const components = getMDXComponents() as any;

/**
* `frontmatter.date` as an ISO 8601 instant, or `undefined`.
*
* The field is declared `z.coerce.string()` in `source.config.ts`, so its type
* says nothing about its shape. Measured against the rendered page rather than
* assumed: what arrives is the plain `2026-07-17` written in the MDX — the same
* string this route already puts in `<time dateTime={...}>` — which `new Date()`
* reads as UTC midnight, giving a value that does not move with the build host's
* timezone.
*
* The guard is still not decoration: `z.coerce.string()` accepts *any* string, so
* a post written with `date: last Tuesday` would type-check and reach here. An
* unparseable date yields no `datePublished` at all rather than a string
* schema.org cannot read.
*/
function isoDate(value: string | undefined): string | undefined {
if (!value) return undefined;
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? undefined : parsed.toISOString();
}

/**
* `BlogPosting` for one post, built from the same `page` object
* `generateMetadata()` reads — same title, same description, same canonical URL,
* same shared card as the Open Graph image.
*
* `dateModified` comes from `app/sitemap.ts`'s own output, so a post's date in the
* JSON-LD and its `<lastmod>` in `/sitemap.xml` are the same value by construction
* rather than by two derivations agreeing.
*
* ⚠️ `author` is emitted as an `Organization`. The frontmatter field is a bare
* string with nothing distinguishing a person from a team, and every value in
* `content/blog` today is `ObjectStack Team` — a team. Guessing `Person` would be
* wrong for all three current posts; a field that can say which is a content-schema
* change, not a JSON-LD one.
*/
function postGraph(url: string, data: BlogPostData): JsonLdNode[] {
const canonical = absoluteUrl(url);

return [
ORGANIZATION,
compact({
'@type': 'BlogPosting',
'@id': `${canonical}#article`,
headline: data.title,
name: data.title,
description: data.description,
url: canonical,
mainEntityOfPage: canonical,
inLanguage: 'en',
image: absoluteUrl(BLOG_CARD.url),
datePublished: isoDate(data.date),
dateModified: sitemapLastModified(url),
keywords: data.tags,
author: data.author ? { '@type': 'Organization', name: data.author } : ORGANIZATION_REF,
publisher: ORGANIZATION_REF,
}),
];
}

export default async function BlogPage({
params,
}: {
Expand DownExpand Up@@ -122,7 +190,8 @@ export default async function BlogPage({
return (
<HomeLayout {...baseOptions()}>
<main className="container max-w-4xl mx-auto px-4 py-16">
<Link
<JsonLd graph={postGraph(page.url, pageData)} />
<Link
href="/blog"
className="inline-flex items-center gap-2 text-sm text-fd-foreground/70 hover:text-fd-foreground mb-8 transition-colors"
>
Expand Down
127 changes: 127 additions & 0 deletions apps/docs/app/[lang]/docs/[[...slug]]/page.tsx
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
import { getPageImage, source } from '@/lib/source';
import type { Metadata } from 'next';
import { DocsBody, DocsDescription, DocsPage, DocsTitle } from 'fumadocs-ui/layouts/docs/page';
import { getBreadcrumbItems } from 'fumadocs-core/breadcrumb';
import { notFound } from 'next/navigation';
import { getMDXComponents } from '@/mdx-components';
import { createRelativeLink } from 'fumadocs-ui/mdx';
Expand All@@ -10,6 +11,131 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
import { LLMCopyButton, ViewOptions } from '@/components/ai/page-actions';
import { gitConfig } from '@/lib/layout.shared';
import { absoluteUrl } from '@/lib/site';
import {
breadcrumbList,
compact,
type Crumb,
JsonLd,
type JsonLdNode,
ORGANIZATION,
ORGANIZATION_REF,
sitemapLastModified,
} from '@/lib/structured-data';

/** The site root, first crumb of every trail. */
const SITE_CRUMB: Crumb = { name: 'ObjectStack', url: absoluteUrl('/') };

/** A page the loader actually resolved — `source.getPage()` minus its `undefined`. */
type DocPage = NonNullable<ReturnType<typeof source.getPage>>;

/**
* Ancestor chain for a doc page, read out of `source.pageTree` — the same tree
* `app/[lang]/docs/layout.tsx` hands to the sidebar, so a crumb and its sidebar
* entry are the same node with the same label.
*
* `getBreadcrumbItems()` is fumadocs' own tree walk (`fumadocs-core/breadcrumb`),
* not a path split: it locates the page node and returns the folders above it,
* each labelled from that folder's `meta.json` title and linked to that folder's
* `index` node. ⛔ Nothing here splits `page.url` on `/`, and nothing constructs a
* URL the loader has not already produced.
*
* ⚠️ **An ancestor arrives without a URL more often than not, and this drops it.**
* Measured against a local production build, not inferred: fumadocs attaches a
* folder's `index.mdx` as that folder's `index` node only when the folder's
* `meta.json` does **not** list `"index"` in `pages`. 17 of the 35 `meta.json`
* files under `content/docs` do list it, so their folder nodes carry a
* `name` and no `url`, and 172 of 403 doc pages therefore ship a trail that skips
* its section. Confirmed causally by deleting that one line from
* `content/docs/data-modeling/meta.json` and rebuilding: the section crumb
* appeared, linked, while an untouched control section stayed short.
*
* ⛔ The missing URL is deliberately **not** reconstructed here. The folder's index
* page exists, is in the sitemap and answers 200 — the defect is in the content
* config that hides it from the tree, not in this consumer, and a lookup that
* re-derived it would make a producer bug invisible and permanent. Google requires
* `item` on every crumb but the last, so a name-only crumb is not an option
* either. Filed separately; when it lands, these trails complete with no change to
* this file.
*
* Two things the tree cannot supply are added around it, both from data this
* page already holds:
*
* - the site root and the docs root, which sit above the tree rather than in it
* (`getBreadcrumbItems`' own `includeRoot` fires only for folders marked
* `root: true` in `meta.json`, which this tree has none of);
* - the page itself as the final crumb, if the walk did not end there — the leaf
* is the one entry a `BreadcrumbList` must not be missing, and `page.data.title`
* with the page's canonical URL is the same pair the `<title>` and the canonical
* link are built from.
*
* Names are `ReactNode` in fumadocs' type; anything that is not a plain string is
* dropped rather than stringified, because `[object Object]` in a crumb is worse
* than a shorter trail.
*/
function docsTrail(
page: DocPage,
lang: string,
canonical: string,
): Crumb[] {
const tree = source.pageTree[lang];
const rootName = typeof tree?.name === 'string' ? tree.name : 'Documentation';

const trail: Crumb[] = [SITE_CRUMB, { name: rootName, url: absoluteUrl('/docs') }];

if (tree) {
for (const item of getBreadcrumbItems(page.url, tree, { includePage: true })) {
if (typeof item.name !== 'string' || !item.url) continue;
const url = absoluteUrl(item.url);
// The docs root is already the second crumb; the tree's own entry for
// `/docs` (the collection's `index.mdx`) must not repeat it.
if (trail.some((crumb) => crumb.url === url)) continue;
trail.push({ name: item.name, url });
}
}

if (trail[trail.length - 1]?.url !== canonical) {
trail.push({ name: page.data.title, url: canonical });
}

return trail;
}

/**
* `TechArticle` + `BreadcrumbList` for one doc page.
*
* Every value comes from the same `page` object `generateMetadata()` below reads,
* so the two layers describe one page rather than two: same title, same
* description, same canonical URL, same Open Graph card as the article `image`.
*
* `dateModified` is read from `app/sitemap.ts`'s own output rather than derived a
* second time — see `sitemapLastModified()`. Pages the sitemap ships without a
* `<lastmod>` get no `dateModified` here either.
*/
function docsGraph(
page: DocPage,
lang: string,
): JsonLdNode[] {
const canonical = absoluteUrl(page.url);

return [
ORGANIZATION,
compact({
'@type': 'TechArticle',
'@id': `${canonical}#article`,
headline: page.data.title,
name: page.data.title,
description: page.data.description,
url: canonical,
mainEntityOfPage: canonical,
inLanguage: lang,
image: absoluteUrl(getPageImage(page).url),
dateModified: sitemapLastModified(page.url),
author: ORGANIZATION_REF,
publisher: ORGANIZATION_REF,
}),
breadcrumbList(`${canonical}#breadcrumb`, docsTrail(page, lang, canonical)),
];
}

export default async function Page(props: {
params: Promise<{ lang: string; slug?: string[] }>;
Expand All@@ -22,6 +148,7 @@ export default async function Page(props: {

return (
<DocsPage toc={page.data.toc} full={page.data.full}>
<JsonLd graph={docsGraph(page, params.lang)} />
<DocsTitle>{page.data.title}</DocsTitle>
<DocsDescription className="mb-0">{page.data.description}</DocsDescription>
<div className="flex flex-row gap-2 items-center border-b pb-6">
Expand Down
55 changes: 54 additions & 1 deletion apps/docs/app/[lang]/page.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,11 +5,20 @@ import { Bricolage_Grotesque, IBM_Plex_Mono } from 'next/font/google';
import { HomeLayout } from 'fumadocs-ui/layouts/home';
import { baseOptions, gitConfig } from '@/lib/layout.shared';
import { absoluteUrl } from '@/lib/site';
import {
APACHE_2_0_URL,
GITHUB_REPO_URL,
JsonLd,
ORGANIZATION,
ORGANIZATION_REF,
SOFTWARE_ID,
YOUTUBE_CHANNEL_URL,
type JsonLdNode,
} from '@/lib/structured-data';
import { YouTubeEmbed } from '@/components/youtube-embed';

/** The 90-second overview — the same video the README's hero cover links to. */
const OVERVIEW_VIDEO_ID = 'CX_FlOoOtr0';
const YOUTUBE_CHANNEL_URL = 'https://www.youtube.com/@objectstack';

const display = Bricolage_Grotesque({
subsets: ['latin'],
Expand DownExpand Up@@ -78,6 +87,49 @@ export const metadata: Metadata = {
},
};

/**
* The homepage's structured data.
*
* ⚠️ **`SoftwareSourceCode`, not `SoftwareApplication`.** Both are `CreativeWork`
* subtypes that carry `license`, so either satisfies the card; the choice is
* about what the validator does with it. Google's Software App rich result
* *requires* `offers`, `aggregateRating` or `review` — ObjectStack is an
* Apache-2.0 runtime with no price, no store listing and no ratings, so a
* `SoftwareApplication` node here would report missing-required-property errors
* in the very test this card is accepted against, in exchange for a rich result
* it can never be eligible for. `SoftwareSourceCode` has no Google rich-result
* feature and therefore no required properties, and `codeRepository` /
* `programmingLanguage` / `runtimePlatform` describe what this project actually
* is.
*
* Every field is drawn from something already on this page or in `lib/`: the
* title and description are the same constants `metadata` uses, the image is
* `HOME_CARD`, the licence is the repo's own, and the two `sameAs` links are the
* GitHub organisation and the YouTube channel this page links to in its hero.
*/
function homeGraph(): JsonLdNode[] {
return [
ORGANIZATION,
{
'@type': 'SoftwareSourceCode',
'@id': SOFTWARE_ID,
name: 'ObjectStack',
url: absoluteUrl('/'),
// Same two strings `metadata` above emits, so the page cannot describe
// itself one way to a crawler and another way to a social card.
headline: HOME_TITLE,
description: HOME_DESCRIPTION,
image: absoluteUrl(HOME_CARD.url),
codeRepository: GITHUB_REPO_URL,
programmingLanguage: 'TypeScript',
runtimePlatform: 'Node.js',
license: APACHE_2_0_URL,
author: ORGANIZATION_REF,
maintainer: ORGANIZATION_REF,
},
];
}

const VOCABULARY: { tag: string; title: string; copy: string }[] = [
{ tag: 'object', title: 'Objects & fields', copy: 'Typed schemas with relations, validation, formulas, and files.' },
{ tag: 'permission', title: 'Permissions', copy: 'RBAC plus row- and field-level security, enforced by the runtime.' },
Expand DownExpand Up@@ -135,6 +187,7 @@ function DiffLine({ children, plain }: { children: React.ReactNode; plain?: bool
export default function HomePage() {
return (
<HomeLayout {...baseOptions()}>
<JsonLd graph={homeGraph()} />
<div
className={`${display.variable} ${mono.variable} relative overflow-hidden`}
style={{
Expand Down
Loading
Loading