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
61 changes: 49 additions & 12 deletions .github/scripts/check-locale-surface.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -192,6 +201,7 @@ const RULES = [
'dotted-slug',
'numeric-character-reference',
'malformed-link-target',
'mdx-comment',
'no-link-targets',
'llms-page-body-missing',
];
Expand Down Expand Up @@ -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`. */
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 &#197 is prose.\n',
'and [Foo](https://en.wikipedia.org/wiki/Foo_(bar)); issue &#197 is prose; `/api/v1/data/*` is a path.\n',
expect: [],
},
];
Expand Down Expand Up @@ -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;
Expand All @@ -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}`,
);
}
Expand Down
52 changes: 29 additions & 23 deletions .github/scripts/check-positioning.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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();
Expand All @@ -73,18 +78,19 @@ const decode = (s) =>
);
const metaDescription = (html) => decode(/<meta name="description" content="([^"]*)"/.exec(html)?.[1]);

/** (a) Each copy is byte-equal to the constant. A missing file reads as undefined, which differs. */
/** (a) Each copy is byte-equal to its constant. A missing file reads as undefined, which differs. */
function ruleA({ ts, index, notFound, llms }) {
const want = composed(ts ?? '');
if (want === undefined) return [`(a) ${CONSTANT} composes no POSITIONING this gate can read`];
const copies = {
[`${INDEX} frontmatter description`]: index && frontmatterDescription(index),
'site-wide meta description (built _not-found.html)': notFound && metaDescription(notFound),
'built /llms.txt summary line': llms?.split('\n').find((l) => 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. */
Expand Down Expand Up @@ -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: '<title>404</title><meta name="description" content="One ontology. It&#x27;s yours."/>',
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: '<meta name="description" content="A self-hosted runtime."/>',
llms: "> One ontology.\nIt's yours.\n",
};
Expand All @@ -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`, `<p>${s} boots.</p>`])), 5],
['(c) list form, a question, a fenced quote, a comment', () => ruleC([glossary('Commercial. Not open source.'), ['a.mdx', C_OK]]), 0],
Expand Down
35 changes: 32 additions & 3 deletions apps/docs/app/[lang]/docs/layout.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<nav aria-label="Legal" className="flex gap-4 px-2 pt-3 text-xs text-fd-muted-foreground">
<Link href={`${prefix}/privacy`} className="hover:text-fd-foreground">
Privacy
</Link>
<Link href={`${prefix}/terms`} className="hover:text-fd-foreground">
Terms
</Link>
</nav>
);
}

export default async function Layout({
params,
Expand All @@ -11,11 +39,12 @@ export default async function Layout({
children: ReactNode;
}) {
const { lang } = await params;

return (
<DocsLayout
tree={source.pageTree[lang]}
<DocsLayout
tree={source.pageTree[lang]}
{...baseOptions(lang)}
sidebar={{ footer: <LegalLinks lang={lang} /> }}
i18n
>
{children}
Expand Down
14 changes: 14 additions & 0 deletions apps/docs/app/[lang]/privacy/page.tsx
Original file line number Diff line number Diff line change
@@ -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: [
{
Expand All @@ -28,6 +31,7 @@ const content = {
},
'zh-Hans': {
title: '隐私政策',
description: 'ObjectStack AI LLC 从公开网站和托管账号收集哪些信息,以及不会收集的自管部署内部数据。',
updated: '最近更新:2026 年 10 月 6 日',
body: [
{
Expand Down Expand Up @@ -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<Metadata> {
const { lang } = await params;
return staticPageMetadata('privacy', lang, content);
}

export default async function PrivacyPage({
params,
}: {
Expand Down
14 changes: 14 additions & 0 deletions apps/docs/app/[lang]/terms/page.tsx
Original file line number Diff line number Diff line change
@@ -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: [
{
Expand Down Expand Up @@ -32,6 +35,7 @@ const content = {
},
'zh-Hans': {
title: '服务条款',
description: 'ObjectOS 各版本的许可方式、本站内容采用的 Apache-2.0 许可、ObjectOS 商标,以及自管部署的责任归属。',
updated: '最近更新:2026 年 10 月 6 日',
body: [
{
Expand Down Expand Up @@ -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<Metadata> {
const { lang } = await params;
return staticPageMetadata('terms', lang, content);
}

export default async function TermsPage({
params,
}: {
Expand Down
Loading
Loading