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
41 changes: 23 additions & 18 deletions .github/scripts/check-locale-surface.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -226,8 +226,12 @@ const SITE_URL = 'https://docs.objectos.ai';
* outcome, not a maintenance tax: unlike the docs counts, it does not move on
* every content PR.
*
* The site root is separate: it exists in every locale because it is a
* language dispatch page that redirects to that locale's `/docs`.
* The site root is deliberately NOT here, and not expected anywhere below. It
* is a language dispatch page that redirects to that locale's `/docs` — a URL
* that serves no content — and `sitemap.ts` stopped submitting it under
* objectos#171, following www.objectos.ai's `noindex` dispatch page excluded
* from its sitemap. A root URL in the artifact is therefore `unexpected-url`,
* which is what keeps the redirect from creeping back in at `priority: 1`.
*/
const STATIC_PAGES = [
{ path: 'privacy', locales: ['en', 'zh-Hans'] },
Expand Down Expand Up @@ -382,16 +386,14 @@ function localeUrl(lang, path, defaultLanguage) {
}

/**
* The full expected sitemap URL set: the root in every locale, the two legal
* pages in the locales they are written in, and every docs page in the locales
* that really have it.
* The full expected sitemap URL set: the two legal pages in the locales they
* are written in, and every docs page in the locales that really have it.
* Not the root — see `STATIC_PAGES`.
*/
function expectedSitemapUrls(surface) {
const { languages, defaultLanguage, pages } = surface;
const urls = new Set();

for (const lang of languages) urls.add(localeUrl(lang, '', defaultLanguage));

for (const { path, locales } of STATIC_PAGES) {
for (const lang of locales) {
if (languages.includes(lang)) urls.add(localeUrl(lang, path, defaultLanguage));
Expand Down Expand Up @@ -1051,8 +1053,9 @@ function evaluate({ surface, artifacts, bodies }) {
// An oracle that expects nothing cannot contradict anything, so a green
// over it is a claim and not a measurement — the same reason `artifact-empty`
// above is a failure rather than a skip. It is reachable only for a
// vocabulary with a `universe`: the sitemap's expected set always holds at
// least the site root in each of the locales `readI18n` guarantees.
// vocabulary with a `universe` in practice: the sitemap's expected set
// holds the legal pages in every locale `STATIC_PAGES` names, so it is
// empty only for a locale list that declares none of them.
if (expected.size === 0) {
findings.push({
rule: 'nothing-expected',
Expand Down Expand Up @@ -1290,14 +1293,11 @@ const BASE_CONTENT = {
const BASE_TITLES = ['Home', 'Guide', 'Deep'];

/**
* The sitemap the base fixture SHOULD produce: root in all three locales, the
* two legal pages in their two, `docs` and `docs/deep` in English only, and
* `docs/guide` in English and Japanese.
* The sitemap the base fixture SHOULD produce: the two legal pages in their
* two locales, `docs` and `docs/deep` in English only, and `docs/guide` in
* English and Japanese. No root in any locale — see `STATIC_PAGES`.
*/
const BASE_URLS = [
'https://docs.objectos.ai',
'https://docs.objectos.ai/zh-Hans',
'https://docs.objectos.ai/ja',
'https://docs.objectos.ai/privacy',
'https://docs.objectos.ai/zh-Hans/privacy',
'https://docs.objectos.ai/terms',
Expand Down Expand Up @@ -1347,6 +1347,14 @@ const CASES = [
name: 'clean baseline',
expect: [],
},
{
// objectos#171, reading 1: the redirecting root used to be submitted in
// every locale at `priority: 1`. It is excluded now, and a sitemap that
// advertises it again is the regression this case keeps red.
name: 'the redirecting root creeps back into the sitemap',
urls: ['https://docs.objectos.ai', 'https://docs.objectos.ai/ja', ...BASE_URLS],
expect: ['unexpected-url'],
},
{
// The #169 defect, in miniature: every page advertised in every locale.
name: 'every page in every locale (the #169 shape)',
Expand Down Expand Up @@ -1499,9 +1507,6 @@ const CASES = [
name: 'no title is exclusive to any locale',
content: { 'index.mdx': mdx('Shared'), 'index.ja.mdx': mdx('Shared') },
urls: [
'https://docs.objectos.ai',
'https://docs.objectos.ai/zh-Hans',
'https://docs.objectos.ai/ja',
'https://docs.objectos.ai/privacy',
'https://docs.objectos.ai/zh-Hans/privacy',
'https://docs.objectos.ai/terms',
Expand Down
173 changes: 173 additions & 0 deletions .github/scripts/check-positioning.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
#!/usr/bin/env node
/**
* The positioning gate for objectos#171. Three rules:
*
* (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
* site-wide meta description on the built `_not-found.html` (that route sets
* only a title), and the built `/llms.txt` `> ` summary line.
* (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.
* (c) The stale sentences #171 removed stay out of the English `content/docs/`
* sources, outside code fences and MDX comments.
*
* Run it after `pnpm turbo run build`; a missing build fails. `--self-test` runs the fixtures.
*/
import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
const CONSTANT = 'apps/docs/lib/positioning.ts';
const INDEX = 'content/docs/index.mdx';
const GLOSSARY = 'content/docs/resources/glossary.mdx';
const BUILD = 'apps/docs/.next/server/app';
const LOCALE_SIBLING = /\.[a-z]{2}(?:-[A-Z][a-z]{3})?\.mdx$/;

const MISSPELT = /\b(?:ObjectStack Protocol|ObjectStack Documentation|Object OS|objectOS|ObjectOs)\b/;
const STALE = [
/ObjectOS\s+is\s+a\s+self-hosted\s+runtime/,
/[Nn]ever\s+phones\s+home/,
/[Dd]oes\s+not\s+call\s+home/,
/[Nn]ever\s+calls\s+home/,
/No\s+licen[cs]e\s+server/, // capital N: "no seats, ..., no license server" is the open runtime and stays
/[Ff]ully\s+self-contained/,
/[Ii]nside\s+your\s+firewall/,
];
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) {
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]);
}

function frontmatterDescription(mdx) {
const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---/.exec(mdx)?.[1] ?? '';
const v = /^description:[ \t]*(.*)$/m.exec(frontmatter)?.[1].trim();
if (v?.startsWith("'")) return v.slice(1, -1).replace(/''/g, "'");
try {
return v?.startsWith('"') ? JSON.parse(v) : v;
} catch {
return v; // malformed quoting is returned raw, so it differs and is reported
}
}

const ENTITY = { amp: '&', quot: '"', lt: '<', gt: '>', apos: "'" };
const decode = (s) =>
s?.replace(/&(?:#x([0-9a-f]+)|#(\d+)|(\w+));/gi, (m, hex, dec, name) =>
hex ? String.fromCodePoint(parseInt(hex, 16)) : dec ? String.fromCodePoint(+dec) : (ENTITY[name] ?? m),
);
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. */
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)}`);
}

/** (b) No shipped file spells the brand another way. A missing file is a finding: it was not measured. */
const ruleB = (files) =>
files.flatMap(([path, text]) => {
if (text === undefined) return [`(b) ${path} does not exist, so it was not measured`];
const m = MISSPELT.exec(text);
return m ? [`(b) ${path} spells the brand ${JSON.stringify(m[0])}; it is ObjectOS`] : [];
});

/** (c) No English source brings a stale sentence back. Fences and comments are blanked, lines kept. */
function ruleC(files) {
const out = [];
const blank = (m) => m.replace(/[^\n]/g, ' ');
for (const [path, text] of files) {
const prose = text.replace(/\{\/\*[\s\S]*?\*\/\}|^ {0,3}(`{3,}|~{3,})[\s\S]*?^ {0,3}\1/gm, blank);
for (const re of STALE) {
const m = re.exec(prose);
const line = m && prose.slice(0, m.index).split('\n').length;
if (m) out.push(`(c) ${path}:${line} brings back ${JSON.stringify(m[0].replace(/\s+/g, ' '))}`);
}
}
const glossary = files.find(([path]) => path === GLOSSARY)?.[1];
const entry = glossary?.split(/\n(?=#)/).find((s) => s.startsWith('### ObjectOS\n'));
if (entry === undefined) out.push(`(c) ${GLOSSARY} has no "### ObjectOS" entry; the open-source check measured nothing`);
else if (OPEN_SOURCE.test(entry)) out.push(`(c) ${GLOSSARY} calls ObjectOS "Open source, Apache-2.0" again`);
return out;
}

function gate() {
const at = (p) => join(ROOT, p);
const read = (p) => (existsSync(at(p)) ? readFileSync(at(p), 'utf8') : undefined);
const list = (dir) => readdirSync(at(dir), { recursive: true }).map((f) => join(dir, f));
if (!existsSync(at(BUILD))) {
console.error(`✗ positioning: ${BUILD} does not exist. Run pnpm turbo run build first.`);
return 1;
}
const pages = list(BUILD).filter((f) => f.endsWith('.html'));
const shipped = [...pages, `${BUILD}/llms.txt.body`, `${BUILD}/llms-full.txt.body`];
const sources = list('content/docs').filter((f) => f.endsWith('.mdx') && !LOCALE_SIBLING.test(f));
const findings = [
...ruleA({ ts: read(CONSTANT), index: read(INDEX), notFound: read(`${BUILD}/_not-found.html`), llms: read(`${BUILD}/llms.txt.body`) }),
...ruleB(shipped.map((p) => [p, read(p)])),
...ruleC(sources.map((p) => [p, read(p)])),
];
for (const f of findings) console.error(` ${f}`);
if (findings.length) {
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`);
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`,
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',
notFound: '<meta name="description" content="A self-hosted runtime."/>',
llms: "> One ontology.\nIt's yours.\n",
};
const B_BAD = ['ObjectStack Protocol', 'ObjectStack Documentation', 'Object OS', 'objectOS', 'ObjectOs'];
const C_OK = 'No seats, no license server. Does ObjectOS phone home?\n\n```\nObjectOS is a self-hosted runtime\n```\n\n{/* was: never phones home */}\n';
const C_BAD = 'ObjectOS is a self-hosted\nruntime. It never phones home, does not call home, never calls home.\n\nNo license server. Fully\nself-contained, inside your firewall.\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],
['(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],
['(c) the eight stale sentences, three wrapped', () => ruleC([glossary('The runtime. Open\nsource, Apache-2.0.'), ['a.mdx', C_BAD]]), 8],
];

function selfTest() {
const wrong = CASES.filter(([name, run, want]) => {
const findings = run();
console.log(`${findings.length === want ? '✓' : '✗'} ${name}: ${findings.length} finding(s), expected ${want}`);
if (findings.length !== want) for (const f of findings) console.error(` ${f}`);
return findings.length !== want;
});
console.log(wrong.length ? `\n✗ self-test: ${wrong.length} case(s) wrong` : '\n✓ self-test: every rule fails its bad fixture and passes its good one');
return wrong.length ? 1 : 0;
}

process.exitCode = process.argv.includes('--self-test') ? selfTest() : gate();
12 changes: 9 additions & 3 deletions .github/scripts/smoke-docs.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -146,10 +146,16 @@ const TARGET_DEFAULTS = {
* table, so it exercises the part of the route tree a shallow check misses.
* Both content pages predate the version currently serving (added 2026-05-24
* and earlier), so this list is runnable against the pinned live version.
*
* The index H1 is `Introduction` since #171 gave `content/docs/index.mdx` a
* real title instead of the bare brand. A version from before that change
* renders `ObjectOS` there and reads as `h1-mismatch` against this list —
* which is what a rollback to such a version would show, and is correct: the
* list describes the site this tree ships, not every version ever served.
*/
const TARGETS = [
{ path: '/', finalPath: '/docs', h1: /^ObjectOS$/i },
{ path: '/en/docs', finalPath: '/docs', h1: /^ObjectOS$/i },
{ path: '/', finalPath: '/docs', h1: /^Introduction$/i },
{ path: '/en/docs', finalPath: '/docs', h1: /^Introduction$/i },
{ path: '/docs/quickstart', finalPath: '/docs/quickstart', h1: /^Quickstart$/i },
{
path: '/docs/build/interface/views',
Expand Down Expand Up @@ -428,7 +434,7 @@ function goodPage({
title = 'Quickstart | ObjectOS',
h1 = 'Quickstart',
links = 20,
prose = 'ObjectOS is a self-hosted runtime for building internal tools. '.repeat(20),
prose = 'The ontology is the software, and this fixture carries enough prose to clear the floor. '.repeat(20),
} = {}) {
const nav = Array.from({ length: links }, (_, i) => `<a href="/docs/page-${i}">Page ${i}</a>`).join('');
return (
Expand Down
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,20 @@ jobs:
shell: bash
run: node .github/scripts/check-locale-surface.mjs | tee -a "$GITHUB_STEP_SUMMARY"

# #171: one positioning constant (`apps/docs/lib/positioning.ts`, quoting
# the objectstack README), one brand spelling, and no stale sentence
# coming back. The brand and the positioning copies are read off the
# BUILT pages and `llms` bodies — the 2026-09-08 mandate is about shipped
# output, and a source scan would flag the code comment in `lib/i18n.ts`
# that the ruling on #171 accepted — so this sits after `build`, on the
# same footing as `Locale surface` above: not a turbo task, its
# `--self-test` under `pnpm turbo run test` below, `shell: bash` for the
# same pipefail reason. The stale-sentence rules read the English sources
# under `content/docs/`, so a finding names the `path:line` to open.
- name: Positioning
shell: bash
run: node .github/scripts/check-positioning.mjs | tee -a "$GITHUB_STEP_SUMMARY"

- run: pnpm turbo run test

# Defect 2 of #269: the deploy used to run its own `pnpm install` and its
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

> ## Build & ask online. Keep the data you own.
>
> ObjectOS is the **commercial runtime environment for
> **The ontology is the software.** ObjectOS is the **commercial runtime environment for
> [ObjectStack](https://github.com/objectstack-ai/objectstack) apps — built
> and operated entirely in the browser**: tell the built-in AI Builder what
> your business needs — a helpdesk, an approval flow, a CRM — and it's running
Expand Down
8 changes: 7 additions & 1 deletion apps/docs/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,20 @@ import './global.css';
import type { ReactNode } from 'react';
import type { Metadata } from 'next';
import { SITE_URL } from '@/lib/seo';
import { POSITIONING } from '@/lib/positioning';

export const metadata: Metadata = {
metadataBase: new URL(SITE_URL),
title: {
template: '%s | ObjectOS',
default: 'ObjectOS',
},
description: 'Customer-hosted runtime for ObjectStack applications. Private, compliant, yours.',
// The site-wide description is the positioning paragraph, one constant
// quoted from the objectstack README (objectos#171, Q4). It used to be a
// literal here that contradicted the index page and the glossary; now
// `check-positioning.mjs` asserts this field reads the constant and that the
// other copies agree with it.
description: POSITIONING,
icons: {
icon: '/logo.svg',
},
Expand Down
30 changes: 15 additions & 15 deletions apps/docs/app/llms.txt/route.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,28 @@
import type { Folder, Item, Node } from 'fumadocs-core/page-tree';
import { llms } from 'fumadocs-core/source/llms';
import { i18n } from '@/lib/i18n';
import { POSITIONING } from '@/lib/positioning';
import { SITE_URL, localeUrl } from '@/lib/seo';
import { source } from '@/lib/source';
import { SITE_NAME, source } from '@/lib/source';

export const revalidate = false;

/**
* The one place this site states what ObjectOS is.
*
* Taken verbatim from the marketing site's own `/llms.txt`
* (`www.objectos.ai`, `src/pages/llms.txt.ts`) — the current authoritative
* positioning string for the two products. It is deliberately NOT re-derived
* from `content/docs/index.mdx`: that page's framing is itself under review,
* and `llms.txt` must not become the place a second version of it appears.
*
* If the positioning changes, change this constant. Nothing else in this file
* encodes it.
* The summary line is the positioning paragraph in `lib/positioning.ts` — the
* one constant this site states ObjectOS in, quoted from the objectstack
* README (objectos#171, Q4). This file used to carry its own literal, taken
* from the marketing site's `/llms.txt`, which was a second version of the
* positioning by construction; both sites now quote the README, and
* `check-positioning.mjs` compares the BUILT summary line against the
* constant, so a literal reintroduced here goes red in CI.
*/
const SUMMARY =
'ObjectStack is the open target format and runtime for AI-written enterprise software; ObjectOS is the commercial production platform where teams build, review, deploy, and operate ObjectStack applications.';
const SUMMARY = POSITIONING;

/** Title line: the product this documentation is for. */
const TITLE = 'ObjectOS';
/**
* Title line: the product this documentation is for, spelled the one way
* `SITE_NAME` spells it.
*/
const TITLE = SITE_NAME;

/** Heading for the pages that sit at the tree root rather than in a section. */
const ROOT_HEADING = 'Overview';
Expand Down
Loading
Loading