diff --git a/.github/scripts/check-locale-surface.mjs b/.github/scripts/check-locale-surface.mjs
index 7f7e02d..cce3c13 100644
--- a/.github/scripts/check-locale-surface.mjs
+++ b/.github/scripts/check-locale-surface.mjs
@@ -162,6 +162,15 @@
* the content-tree oracle above is the thing that says which `llms.mdx`
* bodies must exist.
*
+ * #299 adds a third rule on the same bodies, `mdx-comment`: no `{/*` in either
+ * consumer. An MDX comment is a note to the page's next editor. It renders
+ * nothing, but the processed Markdown kept it as text, so the internal naming
+ * note in `resources/license.mdx` shipped in both consumers and in what Copy
+ * Markdown copies. `getLLMText` in `apps/docs/lib/source.ts` now strips them,
+ * except inside a code fence. This rule reads the whole body, fences included,
+ * for the reason given above. A code sample that one day needs a literal `{/*`
+ * is a reason to change this rule on purpose.
+ *
* ## Usage
*
* node .github/scripts/check-locale-surface.mjs # the gate (needs a build)
@@ -192,6 +201,7 @@ const RULES = [
'dotted-slug',
'numeric-character-reference',
'malformed-link-target',
+ 'mdx-comment',
'no-link-targets',
'llms-page-body-missing',
];
@@ -747,7 +757,9 @@ function scanBody(text) {
if (text[end] !== ')') malformed.push({ index: i, text: text.slice(i, end) });
}
- return { references, targets, malformed };
+ const comments = [...text.matchAll(/\{\/\*/g)].map((m) => ({ index: m.index }));
+
+ return { references, targets, malformed, comments };
}
/** Every `.body` file under `dir`, keyed by its path relative to `dir` without `.body`. */
@@ -775,7 +787,7 @@ function readBodies(dir, base = dir, out = new Map()) {
function encodingFindings(bodies, consumers, scan = scanBody) {
const findings = [];
const tally = new Map(
- consumers.map((c) => [c, { bodies: 0, references: 0, targets: 0, malformed: 0, kinds: new Map() }]),
+ consumers.map((c) => [c, { bodies: 0, references: 0, targets: 0, malformed: 0, comments: 0, kinds: new Map() }]),
);
for (const body of bodies) {
@@ -785,6 +797,7 @@ function encodingFindings(bodies, consumers, scan = scanBody) {
t.references += found.references.length;
t.targets += found.targets;
t.malformed += found.malformed.length;
+ t.comments += found.comments.length;
for (const r of found.references) t.kinds.set(r.text, (t.kinds.get(r.text) ?? 0) + 1);
const where = rel(body.path);
@@ -815,6 +828,17 @@ function encodingFindings(bodies, consumers, scan = scanBody) {
.join('; '),
});
}
+ if (found.comments.length) {
+ const first = found.comments[0].index;
+ findings.push({
+ rule: 'mdx-comment',
+ artifact: body.consumer,
+ detail:
+ `${body.consumer}: ${found.comments.length} MDX comment(s) in ${where}; first at line ` +
+ `${lineAt(body.text, first)}: ${printable(body.text.slice(first, first + 60))} — an editor's ` +
+ 'note shipped as text, which `getLLMText` in `apps/docs/lib/source.ts` strips',
+ });
+ }
}
for (const [consumer, t] of tally) {
@@ -1191,15 +1215,15 @@ function gate() {
'decode a reference back into the character it encodes and make the evidence read clean.\n',
);
console.log(`${control.line}\n`);
- console.log('| consumer | bodies read | bodies expected | numeric references | link targets | malformed targets |');
- console.log('|---|---:|---:|---:|---:|---:|');
+ console.log('| consumer | bodies read | bodies expected | numeric references | link targets | malformed targets | MDX comments |');
+ console.log('|---|---:|---:|---:|---:|---:|---:|');
for (const consumer of LLMS_CONSUMERS) {
const t = encoding.tally.get(consumer);
const expected = consumer === 'llms.mdx' ? encoding.expected : 1;
console.log(
t
- ? `| \`${consumer}\` | ${t.bodies} | ${expected} | ${t.references} | ${t.targets} | ${t.malformed} |`
- : `| \`${consumer}\` | NOT MEASURED — not built | ${expected} | — | — | — |`,
+ ? `| \`${consumer}\` | ${t.bodies} | ${expected} | ${t.references} | ${t.targets} | ${t.malformed} | ${t.comments} |`
+ : `| \`${consumer}\` | NOT MEASURED — not built | ${expected} | — | — | — | — |`,
);
}
for (const [consumer, t] of encoding.tally) {
@@ -1229,7 +1253,7 @@ function gate() {
'✓ every advertised URL has a source file and every source file is advertised; both ' +
`\`llms\` bodies carry every ${surface.defaultLanguage}-only page title and none from ` +
'the other locales; no page slug in the content tree contains a dot; and neither ' +
- '`llms` consumer carries a numeric character reference or a malformed link target',
+ '`llms` consumer carries a numeric character reference, a malformed link target or an MDX comment',
);
return;
}
@@ -1557,6 +1581,18 @@ const CASES = [
}),
expect: ['malformed-link-target'],
},
+ {
+ // #299: the internal note in `resources/license.mdx`, as the processed
+ // Markdown carried it — a whole-line comment over several lines.
+ name: 'llms-full.txt carries an MDX comment',
+ fullBody: `${llmsFull(BASE_TITLES)}\n{/*\n Naming, decided under #79.\n */}\n`,
+ expect: ['mdx-comment'],
+ },
+ {
+ name: 'an llms.mdx body carries an MDX comment',
+ mdx: (pages) => ({ ...pages, 'docs/guide': `${pages['docs/guide']}\nText {/* aside */} more.\n` }),
+ expect: ['mdx-comment'],
+ },
{
// One page's body not built: its text was never scanned, and a clean
// scan of the other bodies must not read as covering it.
@@ -1593,7 +1629,7 @@ const CASES = [
name: 'ampersands and parentheses in a URL are not findings',
fullBody:
`${llmsFull(BASE_TITLES)}\nR&D at AT&T, see [search](https://docs.objectos.ai/docs?q=a&b=c) ` +
- 'and [Foo](https://en.wikipedia.org/wiki/Foo_(bar)); issue Å is prose.\n',
+ 'and [Foo](https://en.wikipedia.org/wiki/Foo_(bar)); issue Å is prose; `/api/v1/data/*` is a path.\n',
expect: [],
},
];
@@ -1826,12 +1862,13 @@ function selfTest() {
}
}
- // Both encoding rules, for both consumers (#282). Rule coverage alone would
- // pass with every reference fixture written against `llms-full.txt`, and
+ // The encoding rules (#282) and `mdx-comment` (#299), for both consumers.
+ // Rule coverage alone would pass with every reference fixture written
+ // against `llms-full.txt`, and
// the 79 `llms.mdx` bodies would then have a rule nobody had seen fire on
// them — the mistake #197 already recorded once.
for (const consumer of LLMS_CONSUMERS) {
- for (const rule of ['numeric-character-reference', 'malformed-link-target']) {
+ for (const rule of ['numeric-character-reference', 'malformed-link-target', 'mdx-comment']) {
if (!pairs.has(`${consumer}:${rule}`)) {
console.error(`✗ no fixture drives "${rule}" red on the ${consumer} consumer`);
failed += 1;
@@ -1846,7 +1883,7 @@ function selfTest() {
console.log(
`✓ self-test: ${CASES.length} case(s) over ${RULES.length} rule(s) and ${ARTIFACTS.length} ` +
'artifact(s) — every rule demonstrated able to fail and every artifact demonstrated ' +
- 'able to fail it, on fixtures read through the real readers; both encoding rules ' +
+ 'able to fail it, on fixtures read through the real readers; the encoding and MDX-comment rules ' +
`demonstrated on both \`llms\` consumers; and the live control demonstrated able to fire ${CONTROL_RULE}`,
);
}
diff --git a/.github/scripts/check-positioning.mjs b/.github/scripts/check-positioning.mjs
index 3017654..ba46a0c 100644
--- a/.github/scripts/check-positioning.mjs
+++ b/.github/scripts/check-positioning.mjs
@@ -4,9 +4,10 @@
*
* (a) Each copy of the positioning equals `apps/docs/lib/positioning.ts` (its
* literals, joined as its own `POSITIONING = [...].join(...)` says): the
- * `content/docs/index.mdx` frontmatter `description` (MDX cannot import), the
+ * opening paragraph of `content/docs/index.mdx` (MDX cannot import), the
* site-wide meta description on the built `_not-found.html` (that route sets
- * only a title), and the built `/llms.txt` `> ` summary line.
+ * only a title), and the built `/llms.txt` `> ` summary line. That file's
+ * frontmatter `description` equals `POSITIONING_SHORT` (#299).
* (b) The brand is spelled ObjectOS in every built HTML page and both `llms`
* bodies. It reads the build, not the sources: `lib/i18n.ts` keeps
* "ObjectStack Documentation" in a comment that ships nowhere, as #171 allowed.
@@ -43,18 +44,22 @@ const STALE = [
];
const OPEN_SOURCE = /Open[\s-]+source,\s+Apache-2\.0/i; // checked inside the glossary's ObjectOS entry only
-/** POSITIONING as the constant's file composes it, or undefined when it cannot be read. */
-function composed(ts) {
+/** The constant file's literals by name, plus POSITIONING as it composes it (undefined when it cannot be read). */
+function constants(ts) {
const literal = {};
for (const m of ts.matchAll(/export const (\w+) =\s*(['"])((?:\\.|(?!\2).)*)\2;/g)) {
literal[m[1]] = m[3].replace(/\\(.)/g, '$1');
}
const join = /export const POSITIONING = \[([^\]]*)\]\.join\((['"])(.*?)\2\)/.exec(ts.replace(/\s*\n\s*/g, ' '));
const parts = join?.[1].split(',').map((s) => s.trim()).filter(Boolean) ?? [];
- if (!parts.length || parts.some((p) => literal[p] === undefined)) return undefined;
- return parts.map((p) => literal[p]).join(join[3]);
+ const ok = parts.length && parts.every((p) => literal[p] !== undefined);
+ return { ...literal, POSITIONING: ok ? parts.map((p) => literal[p]).join(join[3]) : undefined };
}
+/** The first paragraph after the frontmatter, its wrapped lines joined by single spaces. */
+const lede = (mdx) =>
+ mdx.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, '').trim().split(/\r?\n[ \t]*\r?\n/)[0].replace(/[ \t]*\r?\n[ \t]*/g, ' ');
+
function frontmatterDescription(mdx) {
const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---/.exec(mdx)?.[1] ?? '';
const v = /^description:[ \t]*(.*)$/m.exec(frontmatter)?.[1].trim();
@@ -73,18 +78,19 @@ const decode = (s) =>
);
const metaDescription = (html) => decode(/ l.startsWith('> '))?.slice(2),
- };
- return Object.entries(copies)
- .filter(([, got]) => got !== want)
- .map(([what, got]) => `(a) ${what} is not POSITIONING\n got: ${JSON.stringify(got)}\n want: ${JSON.stringify(want)}`);
+ const c = constants(ts ?? '');
+ if (c.POSITIONING === undefined || c.POSITIONING_SHORT === undefined) return [`(a) ${CONSTANT} has no POSITIONING or POSITIONING_SHORT this gate can read`];
+ const copies = [
+ [`${INDEX} frontmatter description`, index && frontmatterDescription(index), 'POSITIONING_SHORT'],
+ [`${INDEX} opening paragraph`, index && lede(index), 'POSITIONING'],
+ ['site-wide meta description (built _not-found.html)', notFound && metaDescription(notFound), 'POSITIONING'],
+ ['built /llms.txt summary line', llms?.split('\n').find((l) => l.startsWith('> '))?.slice(2), 'POSITIONING'],
+ ];
+ return copies
+ .filter(([, got, name]) => got !== c[name])
+ .map(([what, got, name]) => `(a) ${what} is not ${name}\n got: ${JSON.stringify(got)}\n want: ${JSON.stringify(c[name])}`);
}
/** (b) No shipped file spells the brand another way. A missing file is a finding: it was not measured. */
@@ -142,19 +148,19 @@ function gate() {
console.error(`\n✗ positioning: ${findings.length} finding(s). The rules are in this script's header.`);
return 1;
}
- console.log(`✓ positioning: 3 copies equal the constant; the brand is right in ${pages.length} pages and 2 llms bodies; no stale sentence in ${sources.length} English sources and the en entry of ${LEGAL.length} legal pages`);
+ console.log(`✓ positioning: 4 copies equal their constants; the brand is right in ${pages.length} pages and 2 llms bodies; no stale sentence in ${sources.length} English sources and the en entry of ${LEGAL.length} legal pages`);
return 0;
}
/* Self-test: per rule, a good fixture that must give 0 findings and a bad one that must give exactly N. */
const A_OK = {
- ts: "export const A = 'One ontology.';\nexport const B =\n \"It's yours.\";\nexport const POSITIONING = [\n A,\n B,\n].join(' ');\n",
- index: `---\ntitle: Introduction\ndescription: "One ontology. It's yours."\n---\n\nBody.\n`,
+ ts: "export const A = 'One ontology.';\nexport const B =\n \"It's yours.\";\nexport const POSITIONING = [\n A,\n B,\n].join(' ');\nexport const POSITIONING_SHORT = 'Yours.';\n",
+ index: `---\ntitle: Introduction\ndescription: "Yours."\n---\n\nOne ontology.\nIt's yours.\n\nBody.\n`,
notFound: '
404',
llms: "# ObjectOS\n\n> One ontology. It's yours.\n",
};
const A_BAD = {
- index: '---\ndescription: One ontology, yours.\n---\n',
+ index: '---\ndescription: One ontology, yours.\n---\n\nOne ontology. Yours.\n',
notFound: '',
llms: "> One ontology.\nIt's yours.\n",
};
@@ -165,8 +171,8 @@ const LEGAL_OK = "const content = {\n en: {\n text: 'ObjectOS Cloud is hoste
const LEGAL_BAD = " en: {\n text: 'ObjectOS is distributed under the Apache License 2.0. ObjectOS is a customer-hosted\nruntime that does not phone home.',\n },\n";
const glossary = (entry) => [GLOSSARY, `### ObjectOS\n\n${entry}\n\n### ObjectStack\n\nOpen source, Apache-2.0.\n`];
const CASES = [
- ['(a) the three copies equal the constant', () => ruleA(A_OK), 0],
- ['(a) index paraphrased, site meta stale, llms line split', () => ruleA({ ...A_OK, ...A_BAD }), 3],
+ ['(a) the four copies equal their constants, the opening paragraph wrapped', () => ruleA(A_OK), 0],
+ ['(a) index description and paragraph paraphrased, site meta stale, llms line split', () => ruleA({ ...A_OK, ...A_BAD }), 4],
['(b) ObjectOS, the host and the package scope', () => ruleB([['a.html', 'Intro | ObjectOS, docs.objectos.ai, @objectos/docs']]), 0],
['(b) the five wrong spellings, one file each', () => ruleB(B_BAD.map((s, i) => [`${i}.html`, `
${s} boots.
`])), 5],
['(c) list form, a question, a fenced quote, a comment', () => ruleC([glossary('Commercial. Not open source.'), ['a.mdx', C_OK]]), 0],
diff --git a/apps/docs/app/[lang]/docs/layout.tsx b/apps/docs/app/[lang]/docs/layout.tsx
index 063e893..b19886b 100644
--- a/apps/docs/app/[lang]/docs/layout.tsx
+++ b/apps/docs/app/[lang]/docs/layout.tsx
@@ -1,7 +1,35 @@
import { source } from '@/lib/source';
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
+import Link from 'next/link';
import type { ReactNode } from 'react';
import { baseOptions } from '@/lib/layout.shared';
+import { i18n } from '@/lib/i18n';
+
+/**
+ * Links to `/privacy` and `/terms`, at the foot of the docs sidebar (#299).
+ *
+ * Before this, nothing on the site linked to either page; only the sitemap
+ * reached them. The app has no footer of its own, and every docs page renders
+ * this layout's sidebar, on mobile as the drawer, so its `footer` slot reaches
+ * both pages from every docs page in every locale.
+ *
+ * Each link stays in the reader's locale. A locale the page is not written in
+ * renders the English text, and that route names the English URL as canonical.
+ */
+function LegalLinks({ lang }: { lang: string }) {
+ const prefix = lang === i18n.defaultLanguage ? '' : `/${lang}`;
+
+ return (
+
+ );
+}
export default async function Layout({
params,
@@ -11,11 +39,12 @@ export default async function Layout({
children: ReactNode;
}) {
const { lang } = await params;
-
+
return (
- }}
i18n
>
{children}
diff --git a/apps/docs/app/[lang]/privacy/page.tsx b/apps/docs/app/[lang]/privacy/page.tsx
index 4945438..ae59bf7 100644
--- a/apps/docs/app/[lang]/privacy/page.tsx
+++ b/apps/docs/app/[lang]/privacy/page.tsx
@@ -1,10 +1,13 @@
+import type { Metadata } from 'next';
import Link from 'next/link';
import { HomeLayout } from 'fumadocs-ui/layouts/home';
import { baseOptions } from '@/lib/layout.shared';
+import { staticPageMetadata } from '@/lib/seo';
const content = {
en: {
title: 'Privacy Policy',
+ description: 'What ObjectStack AI LLC collects from its public websites and hosted accounts, and the data inside a self-managed deployment that it does not collect.',
updated: 'Last updated: October 6, 2026',
body: [
{
@@ -28,6 +31,7 @@ const content = {
},
'zh-Hans': {
title: '隐私政策',
+ description: 'ObjectStack AI LLC 从公开网站和托管账号收集哪些信息,以及不会收集的自管部署内部数据。',
updated: '最近更新:2026 年 10 月 6 日',
body: [
{
@@ -67,6 +71,16 @@ const content = {
*/
export const contentLocales = Object.keys(content);
+/** Title, description, canonical and hreflang from `content`; see `staticPageMetadata`. */
+export async function generateMetadata({
+ params,
+}: {
+ params: Promise<{ lang: string }>;
+}): Promise {
+ const { lang } = await params;
+ return staticPageMetadata('privacy', lang, content);
+}
+
export default async function PrivacyPage({
params,
}: {
diff --git a/apps/docs/app/[lang]/terms/page.tsx b/apps/docs/app/[lang]/terms/page.tsx
index e80a2ab..0933a9a 100644
--- a/apps/docs/app/[lang]/terms/page.tsx
+++ b/apps/docs/app/[lang]/terms/page.tsx
@@ -1,10 +1,13 @@
+import type { Metadata } from 'next';
import Link from 'next/link';
import { HomeLayout } from 'fumadocs-ui/layouts/home';
import { baseOptions } from '@/lib/layout.shared';
+import { staticPageMetadata } from '@/lib/seo';
const content = {
en: {
title: 'Terms of Service',
+ description: "How ObjectOS editions are licensed, the Apache-2.0 license of this site's content, the ObjectOS trademark, and responsibility for self-managed deployments.",
updated: 'Last updated: October 6, 2026',
body: [
{
@@ -32,6 +35,7 @@ const content = {
},
'zh-Hans': {
title: '服务条款',
+ description: 'ObjectOS 各版本的许可方式、本站内容采用的 Apache-2.0 许可、ObjectOS 商标,以及自管部署的责任归属。',
updated: '最近更新:2026 年 10 月 6 日',
body: [
{
@@ -75,6 +79,16 @@ const content = {
*/
export const contentLocales = Object.keys(content);
+/** Title, description, canonical and hreflang from `content`; see `staticPageMetadata`. */
+export async function generateMetadata({
+ params,
+}: {
+ params: Promise<{ lang: string }>;
+}): Promise {
+ const { lang } = await params;
+ return staticPageMetadata('terms', lang, content);
+}
+
export default async function TermsPage({
params,
}: {
diff --git a/apps/docs/app/og/docs/[...slug]/route.tsx b/apps/docs/app/og/docs/[...slug]/route.tsx
index 92c950c..e6ec6a7 100644
--- a/apps/docs/app/og/docs/[...slug]/route.tsx
+++ b/apps/docs/app/og/docs/[...slug]/route.tsx
@@ -40,6 +40,57 @@ function isLocale(value: string): value is Locale {
return (i18n.languages as readonly string[]).includes(value);
}
+/** A character that renders about twice as wide as a Latin one: Han, kana, Hangul, CJK punctuation, full-width forms. */
+const WIDE = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul} -〿-]/u;
+
+/** Width units the card's 82px title fits on one line, and its 52px description on one line. */
+const TITLE_LINE = 26;
+const DESCRIPTION_LINE = 40;
+
+function widthOf(text: string): number {
+ let units = 0;
+ for (const char of text) units += WIDE.test(char) ? 2 : 1;
+ return units;
+}
+
+/**
+ * The description as the card can show it: whole when it fits, otherwise cut at
+ * the last word boundary inside the budget, with an ellipsis (#299).
+ *
+ * `fumadocs-ui/og` puts the title, the description and the brand line in a
+ * fixed 1200x630 flex column and does not clip any of them. Text that does not
+ * fit is drawn on top of the next element. A 511-character index description
+ * covered the divider and the brand line, and a 186-character one went over a
+ * two-line title.
+ *
+ * The budget is in width units, so a CJK card is cut at the same width as an
+ * English one rather than at the same character count. It is three description
+ * lines, about 120 Latin characters, under a one-line title, and one line fewer
+ * for each extra line the title takes. Scripts without spaces have no word
+ * boundary to find, so they are cut between characters, which is where they
+ * wrap anyway.
+ */
+function clampDescription(description: string | undefined, title: string): string | undefined {
+ if (!description) return description;
+ const lines = Math.max(1, 4 - Math.ceil(widthOf(title) / TITLE_LINE));
+ const budget = lines * DESCRIPTION_LINE;
+ if (widthOf(description) <= budget) return description;
+
+ let head = '';
+ let units = 1; // the ellipsis
+ for (const char of description) {
+ units += WIDE.test(char) ? 2 : 1;
+ if (units > budget) break;
+ head += char;
+ }
+ const space = head.lastIndexOf(' ');
+ if (space > head.length / 2) head = head.slice(0, space);
+ // Never end inside a parenthesis the cut left open: "(ObjectOS…" reads as broken.
+ const open = Math.max(head.lastIndexOf('('), head.lastIndexOf('('));
+ if (open > 0 && open > Math.max(head.lastIndexOf(')'), head.lastIndexOf(')'))) head = head.slice(0, open);
+ return `${head.replace(/[\s,.;:–—\-、。,:;]+$/u, '')}…`;
+}
+
export async function GET(
_req: Request,
{ params }: { params: Promise<{ slug: string[] }> },
@@ -58,7 +109,7 @@ export async function GET(
return new ImageResponse(
,
{
diff --git a/apps/docs/lib/positioning.ts b/apps/docs/lib/positioning.ts
index 9d1fbf3..444a7d1 100644
--- a/apps/docs/lib/positioning.ts
+++ b/apps/docs/lib/positioning.ts
@@ -12,13 +12,14 @@
*
* ## Who reads it
*
- * Three machine-facing surfaces carry `POSITIONING`: the site-wide meta
- * description (`app/layout.tsx`), the `/llms.txt` summary line
- * (`app/llms.txt/route.ts`) and the `description` frontmatter of
- * `content/docs/index.mdx`. MDX frontmatter cannot import, so that third copy is
- * a literal, and `.github/scripts/check-positioning.mjs` compares it to this
+ * Three surfaces carry `POSITIONING`: the site-wide meta description
+ * (`app/layout.tsx`), the `/llms.txt` summary line (`app/llms.txt/route.ts`) and
+ * the opening paragraph of `content/docs/index.mdx`. The `/docs` meta
+ * description carries `POSITIONING_SHORT` instead — the `description`
+ * frontmatter of that same file. MDX cannot import, so both `index.mdx` copies
+ * are literals, and `.github/scripts/check-positioning.mjs` compares each to its
* constant on every pull request — a copy that differs fails CI. The same gate
- * reads the four literals below, so keep each one on the shape it parses: one
+ * reads the literals below, so keep each one on the shape it parses: one
* `export const NAME =` followed by a single quoted string.
*
* ## The one departure from the README's bytes
@@ -68,3 +69,14 @@ export const POSITIONING = [
OBJECTOS_DEFINITION,
OBJECTOS_EDITIONS,
].join(' ');
+
+/**
+ * The positioning in 160 characters or fewer, for the `/docs` meta description
+ * (objectos#299). `POSITIONING` is 511, and a search snippet cut it off inside
+ * the headline, before the word "ObjectOS". So this one leads with ObjectOS and
+ * the two editions. It is built from the words above: the README's definition,
+ * the edition names, and "you own it" from the promise. It is not a fifth
+ * README quote. The full paragraph stays the page's opening paragraph.
+ */
+export const POSITIONING_SHORT =
+ 'ObjectOS is the commercial runtime environment built on ObjectStack, hosted (ObjectOS Cloud) or self-managed (ObjectOS Enterprise). You own the ontology.';
diff --git a/apps/docs/lib/seo.ts b/apps/docs/lib/seo.ts
index 342f9eb..1cc0b9b 100644
--- a/apps/docs/lib/seo.ts
+++ b/apps/docs/lib/seo.ts
@@ -1,5 +1,6 @@
+import type { Metadata } from 'next';
import { i18n } from '@/lib/i18n';
-import { source } from '@/lib/source';
+import { SITE_NAME, source } from '@/lib/source';
import { SITE_HOST } from '@/lib/site';
/**
@@ -96,3 +97,36 @@ export function languageAlternates(
languages['x-default'] = localeUrl(i18n.defaultLanguage, path);
return languages;
}
+
+/**
+ * Head metadata for a page that is not in `content/docs/` and keeps its copy in
+ * a per-locale record in its own route file: `privacy` and `terms` (#299).
+ * Before this, both pages inherited the root layout's head: the title
+ * "ObjectOS", the 511-character site description, and no canonical URL.
+ *
+ * `content` is that record. Its keys are the locales the page is really written
+ * in. Any other locale route renders the English entry, so it names the English
+ * URL as canonical, which is the rule docs pages follow through
+ * `canonicalLocale`. The hreflang cluster lists only the written locales, the
+ * same set `sitemap.ts` advertises for these paths.
+ *
+ * `openGraph.title` is set explicitly so the share preview reads "Privacy
+ * Policy", not the `%s | ObjectOS` tab title. `siteName` already carries the
+ * brand, as it does on docs pages.
+ */
+export function staticPageMetadata(
+ path: string,
+ lang: string,
+ content: Record,
+): Metadata {
+ const contentLang = Object.hasOwn(content, lang) ? lang : i18n.defaultLanguage;
+ const { title, description } = content[contentLang];
+ const canonical = localeUrl(contentLang, path);
+
+ return {
+ title,
+ description,
+ alternates: { canonical, languages: languageAlternates(path, Object.keys(content)) },
+ openGraph: { type: 'website', url: canonical, siteName: SITE_NAME, title, description },
+ };
+}
diff --git a/apps/docs/lib/source.ts b/apps/docs/lib/source.ts
index 2193c56..5feca67 100644
--- a/apps/docs/lib/source.ts
+++ b/apps/docs/lib/source.ts
@@ -57,8 +57,30 @@ export function getPageImage(page: InferPageType, lang: string) {
};
}
+/**
+ * A fenced code block, a whole-line MDX comment with the blank lines after it,
+ * or an inline MDX comment. These are one alternation so that a fence is
+ * consumed whole, and a `{/* … *\/}` inside a code sample is kept as code.
+ */
+const FENCE_OR_MDX_COMMENT =
+ /^ {0,3}(`{3,}|~{3,})[\s\S]*?^ {0,3}\1|^[ \t]*\{\/\*[\s\S]*?\*\/\}[ \t]*(?:\n[ \t]*)*\n|\{\/\*[\s\S]*?\*\/\}/gm;
+
+/**
+ * Removes MDX comments from a page's processed Markdown (#299).
+ *
+ * `{/* … *\/}` is how a page leaves a note for its next editor. It renders
+ * nothing on the page, but fumadocs' processed Markdown keeps it as text, so
+ * `resources/license.mdx`'s internal naming note was shipped in
+ * `/llms-full.txt`, in `/docs/resources/license.mdx` and in what Copy Markdown
+ * copies. `check-locale-surface.mjs` fails the build if an `llms` body carries
+ * one again.
+ */
+function stripMdxComments(markdown: string): string {
+ return markdown.replace(FENCE_OR_MDX_COMMENT, (match, fence?: string) => (fence ? match : ''));
+}
+
export async function getLLMText(page: InferPageType) {
- const processed = await page.data.getText('processed');
+ const processed = stripMdxComments(await page.data.getText('processed'));
return `# ${page.data.title}
diff --git a/content/docs/build/ai-skills.mdx b/content/docs/build/ai-skills.mdx
index 19820ab..b287ce9 100644
--- a/content/docs/build/ai-skills.mdx
+++ b/content/docs/build/ai-skills.mdx
@@ -1,6 +1,6 @@
---
title: IDE Skills (Claude Code / Cursor / Copilot)
-description: Install the ObjectStack skills into your coding agent so Claude Code, Cursor, Copilot, Codex and friends know how to author ObjectStack metadata — the ontology ObjectOS runs — correctly.
+description: Install the ObjectStack skills so Claude Code, Cursor, Copilot, Codex and other coding agents author correct ObjectStack metadata — the ontology ObjectOS runs.
---
The [AI Builder](./ai-builder) lives inside ObjectOS and talks to your
diff --git a/content/docs/configure/permissions/record-access.mdx b/content/docs/configure/permissions/record-access.mdx
index 0b1396e..54e0e57 100644
--- a/content/docs/configure/permissions/record-access.mdx
+++ b/content/docs/configure/permissions/record-access.mdx
@@ -1,6 +1,6 @@
---
title: Record Access
-description: Control which records a user can see or modify.
+description: Control which records a user can see or modify once object permissions allow it — sharing-model defaults, tenant isolation, sharing rules, record shares.
---
Record access controls which rows a user can see or modify after object
diff --git a/content/docs/configure/webhooks.mdx b/content/docs/configure/webhooks.mdx
index 4e9e5b3..88a261f 100644
--- a/content/docs/configure/webhooks.mdx
+++ b/content/docs/configure/webhooks.mdx
@@ -1,6 +1,6 @@
---
title: Webhooks
-description: Outbound webhook delivery, signing, and retries.
+description: Outbound webhooks from ObjectOS via a persistent outbox — at-least-once delivery, HMAC signing, bounded retries, and what a receiver must handle.
---
ObjectOS uses a persistent **outbox** model for outbound webhooks. When
diff --git a/content/docs/deploy/air-gapped.mdx b/content/docs/deploy/air-gapped.mdx
index 06fdc5a..9741f01 100644
--- a/content/docs/deploy/air-gapped.mdx
+++ b/content/docs/deploy/air-gapped.mdx
@@ -1,6 +1,6 @@
---
title: Air-gapped Deployment
-description: Air-gap is a licence mode, not a firewall setting — the two settings a disconnected deployment must declare, and the combinations the runtime refuses at startup.
+description: Air-gap is a licence mode, not a firewall setting — the two settings a disconnected deployment must declare, and the combinations refused at startup.
---
## Air-gap is a licence mode
diff --git a/content/docs/deploy/kubernetes.mdx b/content/docs/deploy/kubernetes.mdx
index 31fc5b8..4a3baab 100644
--- a/content/docs/deploy/kubernetes.mdx
+++ b/content/docs/deploy/kubernetes.mdx
@@ -1,6 +1,6 @@
---
title: Kubernetes
-description: The properties any orchestrator must preserve when running the licensed ObjectOS image — digest pinning, migration ordering, probes, and what multi-replica makes mandatory.
+description: What any orchestrator must preserve to run the licensed ObjectOS image — digest pinning, migration ordering, probes, and what multi-replica makes mandatory.
---
**We ship a Compose stack, not a Helm chart or Kubernetes manifests.** The
diff --git a/content/docs/extend-existing-systems.mdx b/content/docs/extend-existing-systems.mdx
index cc80b81..9142417 100644
--- a/content/docs/extend-existing-systems.mdx
+++ b/content/docs/extend-existing-systems.mdx
@@ -1,6 +1,6 @@
---
title: Extend Existing Systems
-description: Federate a database you already run into ObjectOS as an external datasource — read-only by default, and early — and model the tables you care about as objects, without a migration.
+description: Federate a database you already run into ObjectOS as an external datasource — read-only by default, and early — and model its tables as objects, no migration.
---
Most teams evaluating ObjectOS already have a system of record — a CRM, an
diff --git a/content/docs/index.mdx b/content/docs/index.mdx
index 26b9c65..655d4d2 100644
--- a/content/docs/index.mdx
+++ b/content/docs/index.mdx
@@ -1,8 +1,16 @@
---
title: Introduction
-description: "The ontology is the software. One executable business ontology. AI writes it, the runtime runs it, agents operate it, you own it. Want the same loop hosted, in the browser, nothing to install? That's ObjectOS, the commercial runtime environment built on ObjectStack. ObjectOS runs hosted in the browser with nothing to install (ObjectOS Cloud) or self-managed on your own infrastructure (ObjectOS Enterprise), and on either edition you can export your ontology and run it on the open-source ObjectStack runtime."
+description: "ObjectOS is the commercial runtime environment built on ObjectStack, hosted (ObjectOS Cloud) or self-managed (ObjectOS Enterprise). You own the ontology."
---
+The ontology is the software. One executable business ontology. AI writes
+it, the runtime runs it, agents operate it, you own it. Want the same loop
+hosted, in the browser, nothing to install? That's ObjectOS, the commercial
+runtime environment built on ObjectStack. ObjectOS runs hosted in the browser
+with nothing to install (ObjectOS Cloud) or self-managed on your own
+infrastructure (ObjectOS Enterprise), and on either edition you can export
+your ontology and run it on the open-source ObjectStack runtime.
+
Describe what the business needs to the built-in
[AI Builder](/docs/build/ai-builder) — a helpdesk, an approval flow, a CRM
— or install a template, and the ontology it writes is live at once: REST
diff --git a/content/docs/quickstart.mdx b/content/docs/quickstart.mdx
index 8ffdf45..a13ab55 100644
--- a/content/docs/quickstart.mdx
+++ b/content/docs/quickstart.mdx
@@ -1,6 +1,6 @@
---
title: Quickstart
-description: From zero to a running app on the open-source ObjectStack runtime — install one CLI, run one command. ObjectOS Cloud needs none of it, sign in and build in the browser.
+description: "From zero to a running app on the open-source ObjectStack runtime: one CLI, one command. ObjectOS Cloud needs none of it — sign in and build in the browser."
---
This page boots the **open-source ObjectStack runtime** on your own
diff --git a/content/docs/reference/environment-variables.mdx b/content/docs/reference/environment-variables.mdx
index 7128404..70f439a 100644
--- a/content/docs/reference/environment-variables.mdx
+++ b/content/docs/reference/environment-variables.mdx
@@ -1,6 +1,6 @@
---
title: Environment Variables
-description: The environment contract of a self-hosted ObjectOS deployment — what each variable decides, which combinations are refused at startup, and the names that no longer do anything.
+description: The environment contract of a self-hosted ObjectOS deployment — what each variable decides, which combinations fail at startup, and which names are retired.
---
Environment variables carry **deployment-level** decisions: which image is
diff --git a/content/docs/resources/faq.mdx b/content/docs/resources/faq.mdx
index 6cec279..9555af2 100644
--- a/content/docs/resources/faq.mdx
+++ b/content/docs/resources/faq.mdx
@@ -1,6 +1,6 @@
---
title: FAQ
-description: Answers to questions we get asked the most.
+description: Common questions about ObjectOS — getting started, databases and multi-tenancy, migrations, permissions, integrations, operations, pricing and licensing.
---
## Getting started