diff --git a/.changeset/canonical-docs-host-in-published-links.md b/.changeset/canonical-docs-host-in-published-links.md new file mode 100644 index 0000000000..fcaf9ca034 --- /dev/null +++ b/.changeset/canonical-docs-host-in-published-links.md @@ -0,0 +1,21 @@ +--- +"create-objectstack": patch +"@objectstack/knowledge-ragflow": patch +"@objectstack/plugin-audit": patch +"@objectstack/service-analytics": patch +"@objectstack/service-automation": patch +"@objectstack/service-cache": patch +"@objectstack/service-i18n": patch +"@objectstack/service-job": patch +"@objectstack/service-knowledge": patch +--- + +Point every documentation link in these packages' published READMEs — and in +the project `create-objectstack` scaffolds — at the canonical docs origin +`https://objectstack.ai`, replacing the `docs.objectstack.ai` spelling. + +Both spellings reach the same pages (the alias redirects to the apex, +path-preserving), so no link was broken. The reason it needs a release rather +than an in-repo fix alone: a README ships inside the npm tarball, so the +version already on npm keeps showing the old host to every reader of the +package page until a new one is published. diff --git a/content/docs.site.json b/content/docs.site.json index 00d99abb39..71d9e8f9be 100644 --- a/content/docs.site.json +++ b/content/docs.site.json @@ -2,7 +2,7 @@ "meta": { "title": "Object Protocol", "description": "The Metadata-Driven Documentation Engine for the Low-Code Era.", - "url": "https://protocol.objectstack.ai", + "url": "https://objectstack.ai", "favicon": "https://objectstack.ai/logo.svg" }, "i18n": { diff --git a/docker/Dockerfile b/docker/Dockerfile index f37e2f2098..f0d2e55ef1 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -29,11 +29,11 @@ # # The `#sha256=` fragment is an optional SRI-style integrity pin, verified # before boot; a mismatch refuses to boot. Docs: -# https://docs.objectstack.ai/docs/deployment/self-hosting#artifact-pinned-boot-os_artifact_url +# https://objectstack.ai/docs/deployment/self-hosting#artifact-pinned-boot-os_artifact_url # # Published by .github/workflows/docker-publish.yml on every framework # release; the image tag always matches the @objectstack/cli version inside. -# Docs: https://docs.objectstack.ai/docs/deployment/self-hosting +# Docs: https://objectstack.ai/docs/deployment/self-hosting FROM node:22-slim diff --git a/packages/create-objectstack/README.md b/packages/create-objectstack/README.md index 33203e71ff..48e80cc204 100644 --- a/packages/create-objectstack/README.md +++ b/packages/create-objectstack/README.md @@ -87,7 +87,7 @@ npm run validate # verify metadata: schema + predicates + bindings ``` See the docs: -[Your First Project](https://docs.objectstack.ai/docs/getting-started/your-first-project). +[Your First Project](https://objectstack.ai/docs/getting-started/your-first-project). ## License diff --git a/packages/plugins/knowledge-ragflow/README.md b/packages/plugins/knowledge-ragflow/README.md index 30b6964553..dd17caa000 100644 --- a/packages/plugins/knowledge-ragflow/README.md +++ b/packages/plugins/knowledge-ragflow/README.md @@ -2,7 +2,7 @@ [RAGFlow](https://github.com/infiniflow/ragflow) `IKnowledgeAdapter` for ObjectStack. -Bridges the [Knowledge Protocol](https://docs.objectstack.ai/docs/protocol/knowledge) to a RAGFlow deployment via its HTTP API. RAGFlow handles chunking (DeepDoc), embedding, hybrid retrieval, and reranking; ObjectStack handles metadata-native sources and permission-aware filtering on top of the returned hits. +Bridges the [Knowledge Protocol](https://objectstack.ai/docs/protocol/knowledge) to a RAGFlow deployment via its HTTP API. RAGFlow handles chunking (DeepDoc), embedding, hybrid retrieval, and reranking; ObjectStack handles metadata-native sources and permission-aware filtering on top of the returned hits. ## Why RAGFlow? diff --git a/packages/plugins/plugin-audit/README.md b/packages/plugins/plugin-audit/README.md index 2e5aa3cc36..5fbeee5ff9 100644 --- a/packages/plugins/plugin-audit/README.md +++ b/packages/plugins/plugin-audit/README.md @@ -365,7 +365,7 @@ Permission sets that use the **hierarchy-relative depth scopes** (`own_and_repor `@objectstack/security-enterprise`. The open edition ships no resolver, so those scopes **fail closed to `own`** — they never widen visibility without it. A grant written to let managers read their reports' audit rows will, on an open build, show them only their own. -See [Access depth](https://docs.objectstack.ai/docs/permissions/permission-sets#access-depth--readscope--writescope-adr-0057-d1). +See [Access depth](https://objectstack.ai/docs/permissions/permission-sets#access-depth--readscope--writescope-adr-0057-d1). `sys_comment`'s record-level edit gates resolve the `sharing` service lazily; without it those checks degrade to parent-record read visibility. If the engine exposes no middleware @@ -419,6 +419,6 @@ Apache-2.0. See [LICENSING.md](../../../LICENSING.md). - [@objectstack/plugin-security](../plugin-security/) — permissions, RLS and field-level security - [@objectstack/plugin-auth](../plugin-auth/) — the caller of the `audit` slot's auth-event ingress -- [`services.audit` reference](https://docs.objectstack.ai/docs/kernel/runtime-services/audit-service) — the slot's +- [`services.audit` reference](https://objectstack.ai/docs/kernel/runtime-services/audit-service) — the slot's full event shape, its never-throws failure posture, and why `SettingsAuditSink.record()` is not this slot diff --git a/packages/services/service-analytics/README.md b/packages/services/service-analytics/README.md index ad782c0410..3a59f33976 100644 --- a/packages/services/service-analytics/README.md +++ b/packages/services/service-analytics/README.md @@ -193,4 +193,4 @@ Apache-2.0. See [LICENSING.md](../../../LICENSING.md). - [@objectstack/objectql](../../objectql/) - [@objectstack/driver-memory](../../drivers/driver-memory/) — ships `InMemoryStrategy` -- [Analytics Guide](https://docs.objectstack.ai/docs/data-modeling/analytics) +- [Analytics Guide](https://objectstack.ai/docs/data-modeling/analytics) diff --git a/packages/services/service-automation/README.md b/packages/services/service-automation/README.md index 06fe157f01..e99ead2958 100644 --- a/packages/services/service-automation/README.md +++ b/packages/services/service-automation/README.md @@ -175,7 +175,7 @@ array of `{ field, operator, value }` triples; operator objects such as through `filter`. Unknown keys are rejected at `registerFlow()`. For every other node's `config`, and for loops, parallel blocks, subflows, waits -and error handling, see the maintained reference — **[Flows](https://docs.objectstack.ai/docs/automation/flows)**. +and error handling, see the maintained reference — **[Flows](https://objectstack.ai/docs/automation/flows)**. This README deliberately does not keep a second copy of that per-node reference. ## Expressions @@ -456,7 +456,7 @@ Apache-2.0. See [LICENSING.md](../../../LICENSING.md). ## See Also - [@objectstack/spec/automation](../../spec/src/automation/) -- [Automation](https://docs.objectstack.ai/docs/automation) — the automation docs section: hooks, flows, +- [Automation](https://objectstack.ai/docs/automation) — the automation docs section: hooks, flows, workflows, approvals, webhooks, connectors -- [Automation Protocol](https://docs.objectstack.ai/docs/references/automation) — the complete schema +- [Automation Protocol](https://objectstack.ai/docs/references/automation) — the complete schema reference for the automation protocol diff --git a/packages/services/service-cache/README.md b/packages/services/service-cache/README.md index 32ccff1cc3..a92cedc4f9 100644 --- a/packages/services/service-cache/README.md +++ b/packages/services/service-cache/README.md @@ -148,4 +148,4 @@ Apache-2.0. See [LICENSING.md](../../../LICENSING.md). ## See Also - [@objectstack/spec/contracts](../../spec/src/contracts/) -- [Cache Service](https://docs.objectstack.ai/docs/kernel/contracts/cache-service) +- [Cache Service](https://objectstack.ai/docs/kernel/contracts/cache-service) diff --git a/packages/services/service-i18n/README.md b/packages/services/service-i18n/README.md index 8ea4df871f..068f2312a8 100644 --- a/packages/services/service-i18n/README.md +++ b/packages/services/service-i18n/README.md @@ -193,4 +193,4 @@ Apache-2.0. See [LICENSING.md](../../../LICENSING.md). ## See Also - [@objectstack/spec/system](../../spec/src/system/) — the `translation` metadata schema -- [I18n Standard](https://docs.objectstack.ai/docs/protocol/kernel/i18n-standard) +- [I18n Standard](https://objectstack.ai/docs/protocol/kernel/i18n-standard) diff --git a/packages/services/service-job/README.md b/packages/services/service-job/README.md index 6727b6878b..7eb13c91b4 100644 --- a/packages/services/service-job/README.md +++ b/packages/services/service-job/README.md @@ -183,4 +183,4 @@ Apache-2.0. See [LICENSING.md](../../../LICENSING.md). - [@objectstack/spec/contracts](../../spec/src/contracts/) - [Cron Expression Generator](https://crontab.guru/) -- [Queue Service](https://docs.objectstack.ai/docs/kernel/runtime-services/queue-service) +- [Queue Service](https://objectstack.ai/docs/kernel/runtime-services/queue-service) diff --git a/packages/services/service-knowledge/README.md b/packages/services/service-knowledge/README.md index e348afa10b..e1da58fa6e 100644 --- a/packages/services/service-knowledge/README.md +++ b/packages/services/service-knowledge/README.md @@ -5,7 +5,7 @@ Orchestrator implementing `IKnowledgeService` over pluggable — that's the job of adapter plugins (`knowledge-memory`, `knowledge-ragflow`, …). -See [Knowledge Protocol](https://docs.objectstack.ai/docs/protocol/knowledge). +See [Knowledge Protocol](https://objectstack.ai/docs/protocol/knowledge). ## License diff --git a/scripts/check-published-readme-links.mjs b/scripts/check-published-readme-links.mjs index b5f05abc6d..baf149c41f 100644 --- a/scripts/check-published-readme-links.mjs +++ b/scripts/check-published-readme-links.mjs @@ -51,32 +51,78 @@ // 10 fragment-only (#section) -- not this gate's business // 8 ROOT-RELATIVE -- assertion 1, all findings // 2 relative into `content/docs` -- lands on raw MDX source -// 1 docs.objectstack.ai -- assertion 2 + 3 +// 1 docs.objectstack.ai -- assertions 3 + 4 // -// ## The three assertions, and why they are ordered by cost +// ## The four assertions, and why they are ordered by cost // // 1. ROOT-RELATIVE IS REJECTED OUTRIGHT. No filesystem lookup, and it cannot // false-positive: there is no root-relative href that is correct in a file // rendered off-site. This one assertion closes the whole measured defect -// class, and it is the reason the gate is worth having even if 2 and 3 were -// deleted tomorrow. +// class, and it is the reason the gate is worth having even if the rest +// were deleted tomorrow. // -// 2. A `docs.objectstack.ai/docs/...` DESTINATION RESOLVES TO A REAL PAGE, the -// way Fumadocs routes it. This is the anti-rot half: assertion 1 pushes -// every author toward the absolute form, and an absolute form that 404s is -// the next defect. It reuses `check-docs-redirects`'s `pageCandidates` +// 2. THE ORIGIN IS THE RULED CANONICAL ONE. Also no I/O -- a string compare +// against `CANONICAL_DOCS_ORIGIN`. This is a CONVENTION claim, not a +// reachability one; the section below explains why that distinction is +// what shapes the whole host handling here. +// +// 3. A DOCS-SITE DESTINATION RESOLVES TO A REAL PAGE, the way Fumadocs +// routes it. This is the anti-rot half: assertion 1 pushes every author +// toward the absolute form, and an absolute form that 404s is the next +// defect. It reuses `check-docs-redirects`'s `pageCandidates` // rather than growing a second resolver -- including that resolver's whole // subtlety, that a directory which EXISTS but carries no `index.mdx` is a // 404. A URL the redirect table rescues is accepted, because the reader // does land on a page; `check:docs-redirects` separately guarantees every // redirect destination resolves. // -// 3. A `#fragment` ON SUCH A URL NAMES A REAL HEADING ID, computed by +// 4. A `#fragment` ON SUCH A URL NAMES A REAL HEADING ID, computed by // `check-doc-anchors`'s `headingIds` -- github-slugger plus the // `[#custom-id]` suffix, one Slugger per file so the duplicate-heading // counters match. A call into an existing implementation, not a second // slugger. // +// ## The host split: the classifier ACCEPTS more than the remedy PRESCRIBES +// +// Maintainer ruling, 2026-08-21, verbatim and untranslated: +// +// 「这个仓的文档站规范 URL 是 https://objectstack.ai」 +// +// The tree named three hosts for one site before that ruling was applied: +// `content/docs.site.json` declared `protocol.objectstack.ai`, this gate +// prescribed `docs.objectstack.ai`, and the root README already used the apex. +// The aliases are not separate deployments -- the apex was made primary in +// July, with `www` and `docs` answering 30x to it path-preservingly (that is +// the commit message of the PR that moved the prominent links, and it is the +// only evidence available here: this gate must run with no network, so it +// cannot and does not probe a host). +// +// That is why the two halves are deliberately NOT symmetric: +// +// * `DOCS_HOSTS` -- what the CLASSIFIER accepts -- carries the canonical host +// AND the redirecting aliases. Dropping an alias from this set does not +// reject it; it reclassifies it as `external`, which is out of scope BY +// NAME, so assertions 3 and 4 stop reading it. Tightening here would +// therefore DELETE the page and anchor checks from exactly the URLs most +// likely to rot -- links already shipped inside npm tarballs, which outlive +// any in-repo fix. Accepting an alias costs nothing and keeps it verified. +// +// * `CANONICAL_DOCS_ORIGIN` -- what the REMEDY PRESCRIBES, and what assertion +// 2 requires -- is the ruled origin, alone. A remedy that prescribes one +// host while the classifier silently accepts another is the state this +// split exists to avoid: authors would keep writing whatever they found in +// a neighbouring file, and the gate would keep agreeing with all of it. +// +// So an alias URL is *accepted for checking* and *rejected for authoring*. It +// is followable, and it is still a finding, and those two facts do not +// conflict -- which is exactly why assertion 2 is documented as a convention +// claim rather than folded in with the reachability ones. +// +// This gate prescribed `docs.objectstack.ai` when it landed on 2026-08-18, and +// authors followed it: the census below counted ONE such link at that commit +// and twelve by 2026-08-21. A gate that prescribes is a gate that propagates, +// so the prescription has to be the ruled one. +// // Deliberately NOT asserted: whether a relative link resolves (that is a // different claim, owned by nothing here yet, and `../../../content/docs/x.mdx` // is *followable* -- it just lands on raw MDX source), and whether an external @@ -112,8 +158,30 @@ const SELF = 'scripts/check-published-readme-links.mjs'; const CONTENT_ROOT = join(ROOT, 'content/docs'); const REDIRECTS_FILE = join(ROOT, 'apps/docs/redirects.mjs'); -/** The canonical published-docs origin. `www.` accepted; nothing else is this host. */ -const DOCS_HOSTS = new Set(['docs.objectstack.ai', 'www.docs.objectstack.ai']); +/** + * The one origin an author may write, ruled by the maintainer on 2026-08-21. + * Assertion 2 requires it and every remedy string is built from it -- so the + * prescription cannot drift from the classifier by being edited in one place. + */ +export const CANONICAL_DOCS_ORIGIN = 'https://objectstack.ai'; + +/** + * Every hostname that NAMES this docs site, canonical and alias alike. + * + * Membership decides only whether assertions 2-4 read a URL at all; it is not + * approval of the spelling (assertion 2 is). The aliases 30x to the apex + * path-preservingly, so a link written on one is followable and its page and + * anchor are worth verifying -- see the host-split section in the header for + * why removing one from this set would silently *reduce* coverage. + */ +const DOCS_HOSTS = new Set([ + 'objectstack.ai', + 'www.objectstack.ai', + 'docs.objectstack.ai', + 'www.docs.objectstack.ai', + 'protocol.objectstack.ai', + 'www.protocol.objectstack.ai', +]); /** The route prefix `apps/docs/lib/source.ts` mounts `content/docs` under. */ const DOCS_BASE = '/docs'; @@ -207,7 +275,32 @@ export function absoluteRemedy(dest) { } if (route === null) return null; route = route.replace(/\.(mdx|md)$/, '').replace(/\/index$/, '').replace(/\/+$/, ''); - return `https://docs.objectstack.ai${DOCS_BASE}${route}${suffix}`; + return `${CANONICAL_DOCS_ORIGIN}${DOCS_BASE}${route}${suffix}`; +} + +/** + * The canonical spelling of a destination already on one of `DOCS_HOSTS`, or + * null when it is already canonical (or is not this site's URL at all). + * + * Compares ORIGIN, not hostname: the ruling names a URL, so `http://` on the + * canonical host is a non-canonical origin too, and the rewrite fixes the + * scheme in the same move. Path, query and fragment are carried across + * untouched -- the aliases redirect path-preservingly, so the canonical URL of + * a given page differs from the alias one in the origin and nothing else. + * + * @param {string} dest + * @returns {string|null} + */ +export function canonicalDocsUrl(dest) { + let url; + try { + url = new URL(dest); + } catch { + return null; + } + if (!DOCS_HOSTS.has(url.hostname.toLowerCase())) return null; + if (url.origin === CANONICAL_DOCS_ORIGIN) return null; + return `${CANONICAL_DOCS_ORIGIN}${url.pathname}${url.search}${url.hash}`; } // --------------------------------------------------------------------------- @@ -257,6 +350,7 @@ export function checkDocument({ file, text, contentRoot, table }) { 'protocol-relative': 0, fragment: 0, relative: 0, + 'non-canonical': 0, resolved: 0, redirected: 0, fragments: 0, @@ -280,7 +374,7 @@ export function checkDocument({ file, text, contentRoot, table }) { : `is root-relative. This file is PUBLISHED (npm renders it against npmjs.com, ` + `GitHub against github.com), so this href resolves against neither host's idea ` + `of the repo. It is not a docs path either, so there is no mechanical remedy: ` - + `link the rendered page absolutely (https://docs.objectstack.ai/docs/...) or, ` + + `link the rendered page absolutely (${CANONICAL_DOCS_ORIGIN}${DOCS_BASE}/...) or, ` + `for a file in this repo, use a path relative to this README.`, ); continue; @@ -288,8 +382,25 @@ export function checkDocument({ file, text, contentRoot, table }) { if (kind !== 'docs-site') continue; - // ── 2. the absolute docs URL has to name a page that exists ────────── const url = new URL(dest); + + // ── 2. the origin has to be the ruled one ──────────────────────────── + // NOT a reachability claim: the aliases 30x here. It is the convention + // claim, and it does NOT `continue` -- a link can be both off-convention + // and dead, and the author needs to be told both in one run. + const canonical = canonicalDocsUrl(dest); + if (canonical !== null) { + stats['non-canonical']++; + add( + 'non-canonical-host', + `is written on ${url.origin}, which is not this repo's canonical docs origin. ` + + `Maintainer ruling, 2026-08-21: 「这个仓的文档站规范 URL 是 ${CANONICAL_DOCS_ORIGIN}」. ` + + `The alias does redirect here, so the link is followable — it is the spelling that ` + + `is wrong, and every README copied from this one inherits it. Write: ${canonical}`, + ); + } + + // ── 3. the absolute docs URL has to name a page that exists ────────── const page = resolveDocsPage(contentRoot, url.pathname); if (page === null) { const redirect = firstMatchingSource(table, url.pathname); @@ -310,7 +421,7 @@ export function checkDocument({ file, text, contentRoot, table }) { } stats.resolved++; - // ── 3. the fragment, if any, has to name a heading the page renders ── + // ── 4. the fragment, if any, has to name a heading the page renders ── const fragment = decodeURIComponent(url.hash.replace(/^#/, '')); if (fragment === '') continue; stats.fragments++; @@ -339,7 +450,7 @@ function report(findings, stats, population) { console.log( `✓ check:published-readme-links — ${stats.links} outbound link(s) across ` + `${population} published markdown file(s): 0 root-relative, ` - + `${stats.resolved} docs.objectstack.ai page(s) resolved ` + + `0 non-canonical origin(s), ${stats.resolved} docs-site page(s) resolved ` + `(${stats.redirected} via redirect), ${stats.fragments} anchor(s) verified.`, ); return 0; @@ -353,7 +464,11 @@ function report(findings, stats, population) { console.error( 'A PUBLISHED markdown file (in its package `files` array, `private` unset) is rendered on\n' + 'npm and on GitHub as well as here. The only link form that works on all three is the\n' - + 'absolute one: https://docs.objectstack.ai/docs/.\n' + + `absolute one, on the canonical origin:\n` + + ` ${CANONICAL_DOCS_ORIGIN}${DOCS_BASE}/\n` + + 'An alias host (docs. / protocol. / www.) redirects here, so such a link is followable —\n' + + 'but it is still a finding: the spelling propagates by copy, which is how this gate\n' + + 'grew twelve of them in three days while prescribing one of the aliases itself.\n' + 'There is NO baseline for this gate — the fix is the link.', ); return 1; @@ -454,33 +569,76 @@ function selfTest() { ok('classify: relative is NOT root-relative', classify('../../spec/src/automation/') === 'relative'); ok('classify: bare fragment', classify('#see-also') === 'fragment'); ok('classify: protocol-relative is not root-relative', classify('//example.com/x') === 'protocol-relative'); - ok('classify: docs site', classify('https://docs.objectstack.ai/docs/automation/flows') === 'docs-site'); - ok('classify: docs site (www)', classify('https://www.docs.objectstack.ai/docs/a') === 'docs-site'); + ok('classify: docs site (canonical apex)', classify('https://objectstack.ai/docs/automation/flows') === 'docs-site'); + ok('classify: docs site (docs. alias)', classify('https://docs.objectstack.ai/docs/automation/flows') === 'docs-site'); + ok('classify: docs site (www of the alias)', classify('https://www.docs.objectstack.ai/docs/a') === 'docs-site'); + ok('classify: docs site (protocol. alias)', classify('https://protocol.objectstack.ai/docs/a') === 'docs-site'); ok('classify: docs host outside /docs', classify('https://docs.objectstack.ai/blog/x') === 'docs-host-other'); + ok('classify: canonical host outside /docs', classify('https://objectstack.ai/pricing') === 'docs-host-other'); ok('classify: another host', classify('https://github.com/infiniflow/ragflow') === 'external'); ok('classify: mailto', classify('mailto:hi@objectstack.ai') === 'external'); + // The accept-wide half of the split, pinned as a claim rather than left to + // be inferred: an alias is IN the resolvable class, so assertions 3 and 4 + // read it. Narrow this set and those two checks go silent, not strict. + ok( + 'classify: an alias stays in the class assertions 3 and 4 read', + ['docs.objectstack.ai', 'protocol.objectstack.ai', 'www.objectstack.ai'].every( + (h) => classify(`https://${h}/docs/automation/flows`) === 'docs-site', + ), + ); // ---- absoluteRemedy: both dead spellings, and the refusal ------------- ok( 'remedy: /content/docs/*.mdx -> absolute route', absoluteRemedy('/content/docs/automation/flows.mdx') - === 'https://docs.objectstack.ai/docs/automation/flows', + === 'https://objectstack.ai/docs/automation/flows', ); ok( 'remedy: trailing-slash directory -> bare route', - absoluteRemedy('/content/docs/automation/') === 'https://docs.objectstack.ai/docs/automation', + absoluteRemedy('/content/docs/automation/') === 'https://objectstack.ai/docs/automation', ); ok( 'remedy: site-root-relative -> absolute, fragment preserved', absoluteRemedy('/docs/permissions/permission-sets#access-depth') - === 'https://docs.objectstack.ai/docs/permissions/permission-sets#access-depth', + === 'https://objectstack.ai/docs/permissions/permission-sets#access-depth', ); ok( 'remedy: an explicit /index is dropped, the route carries none', absoluteRemedy('/content/docs/automation/index.mdx') - === 'https://docs.objectstack.ai/docs/automation', + === 'https://objectstack.ai/docs/automation', ); ok('remedy: REFUSED for a non-docs root path', absoluteRemedy('/LICENSING.md') === null); + // The prescribe-narrow half: every remedy this gate emits names the ruled + // origin and no alias. Asserted over the builder's whole output rather than + // one sample, so an alias cannot come back through a branch nobody sampled. + ok( + 'remedy: NEVER prescribes an alias host', + ['/content/docs/a.mdx', '/content/docs/a/', '/docs/a#b', '/content/docs/a/index.mdx'].every( + (d) => absoluteRemedy(d)?.startsWith('https://objectstack.ai/docs/'), + ), + ); + + // ---- canonicalDocsUrl: the host swap, both directions ----------------- + ok( + 'canonical: an alias is rewritten, path and fragment carried', + canonicalDocsUrl('https://docs.objectstack.ai/docs/a/b#c') + === 'https://objectstack.ai/docs/a/b#c', + ); + ok( + 'canonical: the OTHER alias too', + canonicalDocsUrl('https://protocol.objectstack.ai/docs/a') === 'https://objectstack.ai/docs/a', + ); + ok( + 'canonical: a query string survives', + canonicalDocsUrl('https://docs.objectstack.ai/docs/a?q=1') === 'https://objectstack.ai/docs/a?q=1', + ); + ok( + 'canonical: compares ORIGIN, so http on the canonical host is rewritten', + canonicalDocsUrl('http://objectstack.ai/docs/a') === 'https://objectstack.ai/docs/a', + ); + ok('canonical: SILENT on the canonical origin', canonicalDocsUrl('https://objectstack.ai/docs/a') === null); + ok('canonical: SILENT on a host that is not this site', canonicalDocsUrl('https://github.com/o/r') === null); + ok('canonical: SILENT on a relative destination', canonicalDocsUrl('../sibling/README.md') === null); // ---- resolveDocsPage: the pageCandidates subtlety --------------------- ok('resolve: a page file', fixtureResolve('/docs/automation/flows') === 'automation/flows.mdx'); @@ -504,9 +662,13 @@ function selfTest() { ok('A1 FAILS on a root-relative destination', a1.findings.length === 1 && a1.findings[0].kind === 'root-relative'); ok( 'A1 failure carries the absolute remedy', - a1.findings[0]?.detail.includes('https://docs.objectstack.ai/docs/automation/flows'), + a1.findings[0]?.detail.includes('https://objectstack.ai/docs/automation/flows'), + ); + ok( + 'A1 remedy does NOT name an alias host', + !a1.findings[0]?.detail.includes('docs.objectstack.ai'), ); - const a1clean = runDoc('See [Flows](https://docs.objectstack.ai/docs/automation/flows).'); + const a1clean = runDoc('See [Flows](https://objectstack.ai/docs/automation/flows).'); ok('A1 SILENT on the absolute form', a1clean.findings.length === 0); ok('A1 SILENT on a relative link', runDoc('See [spec](../../spec/src/).').findings.length === 0); ok('A1 SILENT on an external URL', runDoc('See [x](https://github.com/o/r).').findings.length === 0); @@ -517,34 +679,78 @@ function selfTest() { ); // Assertion 2 -- observed FAILING, then observed SILENT. - const a2 = runDoc('See [Nope](https://docs.objectstack.ai/docs/automation/no-such-page).'); - ok('A2 FAILS on a docs URL with no page', a2.findings.length === 1 && a2.findings[0].kind === 'dead-page'); - ok('A2 failure names the candidates it tried', a2.findings[0]?.detail.includes('content/docs/automation/no-such-page.mdx')); - ok('A2 SILENT on a docs URL that resolves', a1clean.stats.resolved === 1); - const a2dir = runDoc('See [Automation](https://docs.objectstack.ai/docs/automation).'); - ok('A2 SILENT on a directory WITH an index page', a2dir.findings.length === 0 && a2dir.stats.resolved === 1); - const a2redirect = runDoc('See [Old](https://docs.objectstack.ai/docs/guides/business-logic).'); - ok('A2 SILENT when the redirect table rescues the URL', a2redirect.findings.length === 0); - ok('A2 counts a rescued URL as redirected, not resolved', a2redirect.stats.redirected === 1); + const a2 = runDoc('See [Flows](https://docs.objectstack.ai/docs/automation/flows).'); ok( - 'A2 SILENT on the docs host outside /docs', - runDoc('See [Blog](https://docs.objectstack.ai/blog/hello).').findings.length === 0, + 'A2 FAILS on an alias origin', + a2.findings.length === 1 && a2.findings[0].kind === 'non-canonical-host', + ); + ok( + 'A2 failure prescribes the canonical rewrite of THAT url', + a2.findings[0]?.detail.includes('https://objectstack.ai/docs/automation/flows'), + ); + ok('A2 counts it', a2.stats['non-canonical'] === 1); + ok('A2 FAILS on the other alias too', runDoc('See [F](https://protocol.objectstack.ai/docs/automation/flows).').stats['non-canonical'] === 1); + ok('A2 SILENT on the canonical origin', a1clean.stats['non-canonical'] === 0); + ok( + 'A2 SILENT on another host entirely', + runDoc('See [x](https://github.com/o/r).').stats['non-canonical'] === 0, + ); + // The whole point of accepting the aliases: an alias link is STILL resolved + // and STILL anchor-checked. If a later edit narrows `DOCS_HOSTS`, these two + // go green by going silent -- so they assert the resolution counters, not + // just the absence of a finding. + ok('A2 does not consume the link: an alias page still RESOLVES', a2.stats.resolved === 1); + const a2both = runDoc('See [Nope](https://docs.objectstack.ai/docs/automation/no-such-page).'); + ok( + 'A2 does not `continue`: an alias URL with a dead page yields BOTH findings', + a2both.findings.length === 2 + && a2both.findings.some((f) => f.kind === 'non-canonical-host') + && a2both.findings.some((f) => f.kind === 'dead-page'), ); // Assertion 3 -- observed FAILING, then observed SILENT. + const a3 = runDoc('See [Nope](https://objectstack.ai/docs/automation/no-such-page).'); + ok('A3 FAILS on a docs URL with no page', a3.findings.length === 1 && a3.findings[0].kind === 'dead-page'); + ok('A3 failure names the candidates it tried', a3.findings[0]?.detail.includes('content/docs/automation/no-such-page.mdx')); + ok('A3 SILENT on a docs URL that resolves', a1clean.stats.resolved === 1); + const a3dir = runDoc('See [Automation](https://objectstack.ai/docs/automation).'); + ok('A3 SILENT on a directory WITH an index page', a3dir.findings.length === 0 && a3dir.stats.resolved === 1); + const a3redirect = runDoc('See [Old](https://objectstack.ai/docs/guides/business-logic).'); + ok('A3 SILENT when the redirect table rescues the URL', a3redirect.findings.length === 0); + ok('A3 counts a rescued URL as redirected, not resolved', a3redirect.stats.redirected === 1); + ok( + 'A3 SILENT on the canonical host outside /docs', + runDoc('See [Blog](https://objectstack.ai/blog/hello).').findings.length === 0, + ); + ok( + 'A3 SILENT on an alias host outside /docs (and A2 too — no route mapping to prescribe)', + runDoc('See [Blog](https://docs.objectstack.ai/blog/hello).').findings.length === 0, + ); + + // Assertion 4 -- observed FAILING, then observed SILENT. const anchorPage = 'content/docs/automation/flows.mdx'; const realIds = headingIds(readFileSync(join(ROOT, anchorPage), 'utf8')); if (realIds.length === 0) { - failures.push(`${anchorPage} renders no heading ids — the A3 fixture has rotted`); + failures.push(`${anchorPage} renders no heading ids — the A4 fixture has rotted`); } else { - const good = runDoc(`See [Flows](https://docs.objectstack.ai/docs/automation/flows#${realIds[0]}).`); - ok('A3 SILENT on a fragment that names a real heading', good.findings.length === 0); - ok('A3 counted the anchor it verified', good.stats.fragments === 1); + const good = runDoc(`See [Flows](https://objectstack.ai/docs/automation/flows#${realIds[0]}).`); + ok('A4 SILENT on a fragment that names a real heading', good.findings.length === 0); + ok('A4 counted the anchor it verified', good.stats.fragments === 1); const bad = runDoc( + 'See [Flows](https://objectstack.ai/docs/automation/flows#no-such-heading-anywhere).', + ); + ok('A4 FAILS on a fragment naming no heading', bad.findings.length === 1 && bad.findings[0].kind === 'dead-anchor'); + ok('A4 failure names the page it read', bad.findings[0]?.detail.includes(anchorPage)); + // Anchor coverage survives on an alias host too -- the other half of the + // "accepted for checking" claim, and the one a narrowed DOCS_HOSTS would + // silently drop. + const aliasBad = runDoc( 'See [Flows](https://docs.objectstack.ai/docs/automation/flows#no-such-heading-anywhere).', ); - ok('A3 FAILS on a fragment naming no heading', bad.findings.length === 1 && bad.findings[0].kind === 'dead-anchor'); - ok('A3 failure names the page it read', bad.findings[0]?.detail.includes(anchorPage)); + ok( + 'A4 still reads the anchor when the origin is an alias', + aliasBad.stats.fragments === 1 && aliasBad.findings.some((f) => f.kind === 'dead-anchor'), + ); } // ---- the empty-scan guard is real, not decorative --------------------- @@ -559,8 +765,9 @@ function selfTest() { console.log( '✓ check:published-readme-links --self-test — extraction discrimination (fence, code span,\n' + ' ref-def, pointy brackets, titles), all seven classify buckets, the remedy builder and its\n' - + ' refusal, the pageCandidates directory/index subtlety, and all three assertions observed\n' - + ' both FAILING and SILENT.', + + ' refusal, the canonicaliser both ways, the pageCandidates directory/index subtlety, and\n' + + ' all four assertions observed both FAILING and SILENT — including the host split itself:\n' + + ' an alias origin is a FINDING, and is still resolved and still anchor-checked.', ); }