diff --git a/.changeset/reference-docs-opaque-root-schemas.md b/.changeset/reference-docs-opaque-root-schemas.md new file mode 100644 index 0000000000..a9ec9d030a --- /dev/null +++ b/.changeset/reference-docs-opaque-root-schemas.md @@ -0,0 +1,42 @@ +--- +"@objectstack/spec": patch +--- + +fix(spec): reference pages no longer drop the whole section for a non-object root schema (#7658) + +`build-docs.ts` decided which node of a published document it was documenting by +enumerating shapes — `properties`, `enum`, `anyOf`, `oneOf` — and answered "none +of those" with `return ''`. A JSON Schema root is routinely none of those: +`z.string().describe(…)` compiles to a bare scalar, `z.record(…)` to an object +with `additionalProperties` and no `properties`, `z.array(…)` to an array, +`z.intersection(…)` to an `allOf`. Every one of them lost its ENTIRE `## Name` +section — heading included, and with it the `.describe()` prose an author wrote +to be read — while the page's `## TypeScript Usage` block, which is spelled from +the export surface rather than from this function, went on naming the export. The +page read as if it had forgotten to finish rendering an entry it had just +imported. + +**Measured on today's tree: 45 published schemas were in that state, 33 of them +carrying a description.** The filed issue counted 22 (`~23`), having scanned only +the bare-scalar spelling; record maps, one array root and one `allOf` are the +same defect through the same line. Regenerating restores all 45 sections — 244 +added lines, **zero removed**, so none of the 1533 sections that already rendered +moves. Emitted file count is unchanged at 230: a section rendering as the empty +string never removed a page, so no category's `pages.length` changes and the +`meta.json` emit guard added in #7303 is untouched. + +Nothing was failing, which is why this stood for months: `check:docs` compares +generated output to committed output, so a section that never existed stays green +forever, and there is no grep for what is missing. + +The renderer moved to `scripts/lib/schema-section.ts` to be pinned directly — +the same extraction, for the same reason, that `lib/format-type.ts` got at #4912. +Its output was the empty string, the one thing grepping emitted `.mdx` cannot +see. + +Sections for these shapes now carry the schema's description plus a single +`**Type:**` line, rendered by the same `formatType` that every property row on +the page uses, so a schema's own section and a property typed with it cannot +disagree. Constraints (`pattern`, `minLength`, `minimum`) are deliberately not +spelled: this renderer prints none in any position, and `json-schema/` stays the +authority on them. diff --git a/content/docs/references/api/contract.mdx b/content/docs/references/api/contract.mdx index d7d1d77761..253175cbd9 100644 --- a/content/docs/references/api/contract.mdx +++ b/content/docs/references/api/contract.mdx @@ -482,6 +482,12 @@ const result = ApiErrorSchema.parse(data); --- +## RecordData + +Key-value map of record data + +**Type:** `Record` + --- diff --git a/content/docs/references/api/websocket.mdx b/content/docs/references/api/websocket.mdx index eb3d1172be..2d99cb1fc9 100644 --- a/content/docs/references/api/websocket.mdx +++ b/content/docs/references/api/websocket.mdx @@ -178,6 +178,12 @@ const result = AckMessageSchema.parse(data); --- +## EventPattern + +Event pattern (supports wildcards like "record.*" or "*.created") + +**Type:** `string` + --- diff --git a/content/docs/references/cloud/environment-artifact.mdx b/content/docs/references/cloud/environment-artifact.mdx index 65b3d69dce..c42862dfdd 100644 --- a/content/docs/references/cloud/environment-artifact.mdx +++ b/content/docs/references/cloud/environment-artifact.mdx @@ -34,6 +34,12 @@ const result = Sha256DigestSchema.parse(data); --- +## Sha256Digest + +SHA-256 digest (64 hex chars) + +**Type:** `string` + --- diff --git a/content/docs/references/cloud/environment.mdx b/content/docs/references/cloud/environment.mdx index 22e3a728c0..21fb0f4793 100644 --- a/content/docs/references/cloud/environment.mdx +++ b/content/docs/references/cloud/environment.mdx @@ -107,6 +107,12 @@ Credential lifecycle status --- +## EnvironmentDriver + +Data-plane driver key (e.g. `turso`, `libsql`, `sqlite`, `postgres`) + +**Type:** `string` + --- diff --git a/content/docs/references/cloud/package.mdx b/content/docs/references/cloud/package.mdx index b4f85dc169..6461ada311 100644 --- a/content/docs/references/cloud/package.mdx +++ b/content/docs/references/cloud/package.mdx @@ -91,9 +91,21 @@ Register a new package in the Control Plane --- +## PackageCategory + +Package category for marketplace discovery (e.g. "crm", "hr", "finance", "devtools") + +**Type:** `string` + --- +## PackageLocale + +BCP-47 locale tag + +**Type:** `string` + --- @@ -128,6 +140,12 @@ Per-locale overrides for a package listing --- +## PackageTranslations + +Locale-keyed overrides; missing keys fall back to base columns + +**Type:** `Record` + --- diff --git a/content/docs/references/cloud/tenant.mdx b/content/docs/references/cloud/tenant.mdx index e11dbd70e5..b24f36fcb1 100644 --- a/content/docs/references/cloud/tenant.mdx +++ b/content/docs/references/cloud/tenant.mdx @@ -158,6 +158,12 @@ const result = PackageInstallationSchema.parse(data); --- +## TenantPlan + +Opaque plan/tier identifier. The vocabulary is control-plane config owned by the cloud distribution, not protocol — this schema accepts any string and does not enumerate valid values. Convention: an empty or unrecognized value is treated as the free tier by cloud-side readers (not enforced by this schema). + +**Type:** `string` + --- diff --git a/content/docs/references/data/context-tokens.mdx b/content/docs/references/data/context-tokens.mdx index 79f5d19def..9e34c9f1ce 100644 --- a/content/docs/references/data/context-tokens.mdx +++ b/content/docs/references/data/context-tokens.mdx @@ -92,9 +92,17 @@ const result = ContextTokenSchema.parse(data); --- +## ContextToken + +**Type:** `string` + --- +## ContextTokenPlaceholder + +**Type:** `string` + --- diff --git a/content/docs/references/data/datasource.mdx b/content/docs/references/data/datasource.mdx index 23d8197f11..5502b0dd1f 100644 --- a/content/docs/references/data/datasource.mdx +++ b/content/docs/references/data/datasource.mdx @@ -68,6 +68,12 @@ const result = DatasourceSchema.parse(data); --- +## DriverType + +Underlying driver identifier + +**Type:** `string` + --- diff --git a/content/docs/references/data/date-macros.mdx b/content/docs/references/data/date-macros.mdx index 8b8da4ff5a..16ae5053f8 100644 --- a/content/docs/references/data/date-macros.mdx +++ b/content/docs/references/data/date-macros.mdx @@ -92,9 +92,17 @@ const result = DateMacroPlaceholderSchema.parse(data); --- +## DateMacroPlaceholder + +**Type:** `string` + --- +## DateMacroToken + +**Type:** `string` + --- diff --git a/content/docs/references/data/driver-common.mdx b/content/docs/references/data/driver-common.mdx index 8aeb9b83aa..36612a7f5f 100644 --- a/content/docs/references/data/driver-common.mdx +++ b/content/docs/references/data/driver-common.mdx @@ -36,6 +36,12 @@ const result = DriverSslToggleSchema.parse(data); --- +## DriverSslToggle + +Enable TLS. Certificates go in the datasource-level `ssl` block. + +**Type:** `boolean` + --- diff --git a/content/docs/references/data/field-value.mdx b/content/docs/references/data/field-value.mdx index aebc1644f3..531a3e012d 100644 --- a/content/docs/references/data/field-value.mdx +++ b/content/docs/references/data/field-value.mdx @@ -83,9 +83,17 @@ const result = AddressSchema.parse(data); --- +## CalendarDateValue + +**Type:** `string` + --- +## ClockTimeValue + +**Type:** `string` + --- @@ -119,6 +127,10 @@ Type: `string` --- +## FileReferenceIdValue + +**Type:** `string` + --- @@ -138,6 +150,10 @@ Type: `string` --- +## InstantValue + +**Type:** `string` + --- @@ -155,6 +171,10 @@ Type: `string` --- +## ReferenceIdValue + +**Type:** `string` + --- diff --git a/content/docs/references/data/filter.mdx b/content/docs/references/data/filter.mdx index 8d5ad2bcae..d7762e21f0 100644 --- a/content/docs/references/data/filter.mdx +++ b/content/docs/references/data/filter.mdx @@ -99,6 +99,10 @@ Type: `[FilterArray](#filterarray)[]` --- +## FilterCondition + +**Type:** `Record & { $and?: [FilterCondition](#filtercondition)[]; $or?: [FilterCondition](#filtercondition)[]; $not?: [FilterCondition](#filtercondition) }` + --- diff --git a/content/docs/references/data/query.mdx b/content/docs/references/data/query.mdx index 5175c97754..29e52ba672 100644 --- a/content/docs/references/data/query.mdx +++ b/content/docs/references/data/query.mdx @@ -67,6 +67,10 @@ const result = AggregationFunction.parse(data); --- +## FieldNode + +**Type:** `string` + --- diff --git a/content/docs/references/kernel/manifest.mdx b/content/docs/references/kernel/manifest.mdx index 0e2042251f..11fdc224c2 100644 --- a/content/docs/references/kernel/manifest.mdx +++ b/content/docs/references/kernel/manifest.mdx @@ -99,6 +99,12 @@ Plugin compatibility ranges (ADR-0025 §3.2) --- +## PluginIntegrity + +Per-file content digests of the plugin artifact (ADR-0025 §3.2) + +**Type:** `Record` + --- diff --git a/content/docs/references/kernel/plugin-structure.mdx b/content/docs/references/kernel/plugin-structure.mdx index baddbb0bae..85224ef789 100644 --- a/content/docs/references/kernel/plugin-structure.mdx +++ b/content/docs/references/kernel/plugin-structure.mdx @@ -43,6 +43,12 @@ Scanned domain module representing a plugin folder --- +## OpsFilePath + +Validates a file path against OPS naming conventions + +**Type:** `string` + --- diff --git a/content/docs/references/qa/testing.mdx b/content/docs/references/qa/testing.mdx index 65ecec928d..e071348640 100644 --- a/content/docs/references/qa/testing.mdx +++ b/content/docs/references/qa/testing.mdx @@ -91,6 +91,12 @@ Comparison operator for test assertions --- +## TestContext + +Initial context or variables for the test + +**Type:** `Record` + --- diff --git a/content/docs/references/shared/branded-types.mdx b/content/docs/references/shared/branded-types.mdx index 536cf256b9..88d8a3bf4a 100644 --- a/content/docs/references/shared/branded-types.mdx +++ b/content/docs/references/shared/branded-types.mdx @@ -38,21 +38,57 @@ const result = AppNameSchema.parse(data); --- +## AppName + +Branded app name (system identifier) + +**Type:** `string` + --- +## FieldName + +Branded field name (snake_case, no dots) + +**Type:** `string` + --- +## FlowName + +Branded flow name (system identifier) + +**Type:** `string` + --- +## ObjectName + +Branded object name (snake_case, no dots) + +**Type:** `string` + --- +## RoleName + +Branded role name (system identifier) + +**Type:** `string` + --- +## ViewName + +Branded view name (system identifier) + +**Type:** `string` + --- diff --git a/content/docs/references/shared/identifiers.mdx b/content/docs/references/shared/identifiers.mdx index 57d23c55cb..740ca79be9 100644 --- a/content/docs/references/shared/identifiers.mdx +++ b/content/docs/references/shared/identifiers.mdx @@ -63,12 +63,30 @@ const result = EventNameSchema.parse(data); --- +## EventName + +Event name (lowercase with dot notation for namespacing) + +**Type:** `string` + --- +## SnakeCaseIdentifier + +Snake case identifier (lowercase with underscores only) + +**Type:** `string` + --- +## SystemIdentifier + +System identifier (lowercase with underscores or dots) + +**Type:** `string` + --- diff --git a/content/docs/references/system/auth-config.mdx b/content/docs/references/system/auth-config.mdx index ea40d47098..412686c9a5 100644 --- a/content/docs/references/system/auth-config.mdx +++ b/content/docs/references/system/auth-config.mdx @@ -179,9 +179,21 @@ OIDC / Generic OAuth2 provider configuration for enterprise SSO --- +## OidcProvidersConfig + +List of OIDC/OAuth2 providers for enterprise SSO. Product or enterprise packages can pass this directly or contribute it through auth:configure. + +**Type:** `{ providerId: string; name?: string; discoveryUrl?: string; issuer?: string; … }[]` + --- +## SocialProviderConfig + +Social/OAuth provider map forwarded to better-auth socialProviders. Keys are provider ids (google, github, apple, …). + +**Type:** `Record>` + --- diff --git a/content/docs/references/system/core-services.mdx b/content/docs/references/system/core-services.mdx index 1c3de7d6fe..897bbc0827 100644 --- a/content/docs/references/system/core-services.mdx +++ b/content/docs/references/system/core-services.mdx @@ -52,6 +52,10 @@ const result = CoreServiceName.parse(data); --- +## KernelServiceMap + +**Type:** `Record` + --- diff --git a/content/docs/references/system/environment-artifact.mdx b/content/docs/references/system/environment-artifact.mdx index 007e251a84..d4240921d3 100644 --- a/content/docs/references/system/environment-artifact.mdx +++ b/content/docs/references/system/environment-artifact.mdx @@ -64,6 +64,12 @@ const result = Sha256DigestSchema.parse(data); --- +## Sha256Digest + +SHA-256 digest (64 hex chars) + +**Type:** `string` + --- diff --git a/content/docs/references/system/metrics.mdx b/content/docs/references/system/metrics.mdx index c1975e9bde..7e8b5ad73e 100644 --- a/content/docs/references/system/metrics.mdx +++ b/content/docs/references/system/metrics.mdx @@ -166,6 +166,12 @@ Metric export configuration --- +## MetricLabels + +Metric labels + +**Type:** `Record` + --- diff --git a/content/docs/references/system/tracing.mdx b/content/docs/references/system/tracing.mdx index d2523cbc00..93998fa7f6 100644 --- a/content/docs/references/system/tracing.mdx +++ b/content/docs/references/system/tracing.mdx @@ -169,6 +169,12 @@ Type: `boolean[]` --- +## SpanAttributes + +Span attributes + +**Type:** `Record` + --- @@ -265,6 +271,12 @@ Trace context propagation --- +## TraceFlags + +Trace flags bitmap + +**Type:** `integer` + --- diff --git a/content/docs/references/system/translation.mdx b/content/docs/references/system/translation.mdx index 617b2732a4..12c9e89c78 100644 --- a/content/docs/references/system/translation.mdx +++ b/content/docs/references/system/translation.mdx @@ -69,6 +69,12 @@ Translation data for a single field --- +## Locale + +BCP-47 Language Tag (e.g. en-US, zh-CN) + +**Type:** `string` + --- @@ -92,6 +98,12 @@ Translation data for a single object --- +## TranslationBundle + +Map of locale codes to translation data + +**Type:** `Record; apps?: Record; messages?: Record; globalActions?: Record; … }>` + --- diff --git a/content/docs/references/ui/i18n.mdx b/content/docs/references/ui/i18n.mdx index 2f89b877ee..93020ec17c 100644 --- a/content/docs/references/ui/i18n.mdx +++ b/content/docs/references/ui/i18n.mdx @@ -78,6 +78,12 @@ Type: `Record` --- +## InlineLocaleMap + +Inline locale map: BCP-47 tag → translated string + +**Type:** `Record` + --- diff --git a/content/docs/references/ui/responsive.mdx b/content/docs/references/ui/responsive.mdx index 530fcd00a0..156c84049c 100644 --- a/content/docs/references/ui/responsive.mdx +++ b/content/docs/references/ui/responsive.mdx @@ -103,6 +103,12 @@ Per-breakpoint scoped style maps (ADR-0065) --- +## StyleMap + +CSS property → value map (camelCase keys; design tokens encouraged) + +**Type:** `Record` + --- diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index 8eae7a9a45..7680fe43f7 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -936,6 +936,12 @@ This schema accepts one of the following structures: --- +## ViewItemName + +Globally-unique view id, `.`. + +**Type:** `string` + --- diff --git a/packages/spec/scripts/build-docs.ts b/packages/spec/scripts/build-docs.ts index aa854926bc..738baadd1d 100644 --- a/packages/spec/scripts/build-docs.ts +++ b/packages/spec/scripts/build-docs.ts @@ -34,9 +34,8 @@ import { serializeImportBaseline, type CategorySurface, } from './lib/docs-import-surface'; -import { escapeMdxDescription } from './lib/escape-mdx'; import { renderFileDescription } from './lib/file-description'; -import { anchorFor, formatPropertyType, formatType, type TypeContext } from './lib/format-type'; +import { anchorFor } from './lib/format-type'; import { createSink } from './lib/generated-output'; import { blurbCoverage, @@ -53,6 +52,7 @@ import { type ZodFileInput, } from './lib/schema-index'; import { schemaNameFromExportKey } from './lib/schema-name'; +import { renderSchemaSection } from './lib/schema-section'; import { API_SURFACE_DIR_NAME, readApiSurfaceFrom } from './lib/sharded-artifacts'; const SCHEMA_DIR = path.resolve(__dirname, '../json-schema'); @@ -365,112 +365,16 @@ function sourcePathToDocsRoute(target: string): string | null { // is a page-level fact, and every use of it (title, source link, card) lives in // `generateZodFileMarkdown` around this call. Underscored rather than dropped so // this touches one line of a renderer PR #6377 is editing (#5475). +// +// The section itself is rendered by `lib/schema-section.ts`, extracted at #7658 +// for the reason `lib/format-type.ts` was at #4912: this file is a top-level +// script with side effects, so the only way to assert on a section used to be to +// run the whole generator and grep the emitted `.mdx` — and the defect #7658 +// fixed was a section rendered as the EMPTY STRING, i.e. precisely the output +// grepping emitted pages cannot see. `scripts/schema-section.test.ts` pins it +// directly now. function generateMarkdown(schemaName: string, schema: any, category: string, _zodFile: string) { - const defs = schema.definitions || schema.$defs || {}; - let mainDef = defs[schemaName]; - - // If the schema name isn't in definitions, check if the root schema itself - // has type/properties/enum (JSON Schema 2020-12 puts content at root level) - if (!mainDef && (schema.properties || schema.enum || schema.anyOf || schema.oneOf)) { - mainDef = schema; - } - - // Last resort: use first definition entry - if (!mainDef) { - mainDef = Object.values(defs)[0]; - } - - if (!mainDef) return ''; - - let md = ''; - - // Add schema heading - md += `## ${schemaName}\n\n`; - - // Description text is made MDX-safe by `lib/escape-mdx.ts` — extracted so the - // escaping can be pinned directly instead of by grepping emitted `.mdx`. - - // Add description with better formatting - if (mainDef.description) { - md += `${escapeMdxDescription(mainDef.description)}\n\n`; - } - - const typeCtx: TypeContext = { defs, currentSchema: schemaName, schemaHref: schemaHrefFrom(category) }; - - const renderProperties = (props: any, required: Set = new Set()) => { - // Vocabularies too wide for their own table cell. Collected while the - // table is built and printed as `### Allowed Values` bullets right after - // it, so the complete list never leaves the page the cell sits on - // (#6225) — the same rendering a hoisted `type: 'string'` + `enum` schema - // has always got, now reachable from a property position too. - const relocated: Array<{ key: string; members: string[] }> = []; - let t = `### Properties\n\n`; - t += `| Property | Type | Required | Description |\n`; - t += `| :--- | :--- | :--- | :--- |\n`; - for (const [key, prop] of Object.entries(props) as [string, any][]) { - const { cell, allowedValues } = formatPropertyType(prop, typeCtx); - if (allowedValues) relocated.push({ key, members: allowedValues }); - // Backslashes first, then pipes — same order as `desc` below, and for - // the same reason: escaping pipes first lets a literal backslash in - // the input pair with the escape and free the pipe again. - const typeStr = cell - .replace(/\\/g, '\\\\') - .replace(/\|/g, '\\|'); - const isReq = required.has(key) ? '✅' : 'optional'; - // Escape for the GFM table cell last: backslashes first (so an existing - // `\|` in a description can't decay into an escaped backslash + live - // pipe), then pipes — an unescaped `|` (even inside a code span) - // splits the cell. - const desc = escapeMdxDescription((prop.description || '').replace(/\n/g, ' ')) - .replace(/\\/g, '\\\\') - .replace(/\|/g, '\\|'); - t += `| **${key}** | \`${typeStr}\` | ${isReq} | ${desc} |\n`; - } - t += '\n'; - // Qualified by schema AND property: `api/errors.mdx` carries a wide - // `code` on both `EnhancedApiError` and `FieldError`, so a heading naming - // only the property would give one page two identical anchors. - for (const { key, members } of relocated) { - t += `### Allowed Values: \`${schemaName}.${key}\`\n\n`; - t += members.map(m => `* \`${m}\``).join('\n'); - t += `\n\n`; - } - return t; - }; - - if (mainDef.type === 'object' && mainDef.properties) { - md += renderProperties(mainDef.properties, new Set(mainDef.required || [])); - - } else if (mainDef.type === 'string' && mainDef.enum) { - md += `### Allowed Values\n\n`; - md += mainDef.enum.map((e: string) => `* \`${e}\``).join('\n'); - md += `\n\n`; - - } else if (mainDef.anyOf || mainDef.oneOf) { - md += `### Union Options\n\nThis schema accepts one of the following structures:\n\n`; - const variants = mainDef.anyOf || mainDef.oneOf; - variants.forEach((variant: any, index: number) => { - const variantTitle = variant.title || `Option ${index + 1}`; - md += `#### ${variantTitle}\n\n`; - if (variant.description) md += `${escapeMdxDescription(variant.description)}\n\n`; - - if (variant.type === 'object' && variant.properties) { - if (variant.properties.type && variant.properties.type.const) { - md += `**Type:** \`${variant.properties.type.const}\`\n\n`; - } - md += renderProperties(variant.properties, new Set(variant.required || [])); - } else if (variant.enum) { - md += `Allowed Values: ${variant.enum.map((e:string) => `\`${e}\``).join(', ')}\n\n`; - } else if (variant.$ref) { - md += `Reference: ${formatType(variant, typeCtx)}\n\n`; - } else { - md += `Type: \`${formatType(variant, typeCtx)}\`\n\n`; - } - md += `---\n\n`; - }); - } - - return md; + return renderSchemaSection(schemaName, schema, { schemaHref: schemaHrefFrom(category) }); } function generateZodFileMarkdown(zodFile: string, schemas: Array<{name: string, content: any}>, category: string): string { diff --git a/packages/spec/scripts/lib/schema-section.ts b/packages/spec/scripts/lib/schema-section.ts new file mode 100644 index 0000000000..ad4283260c --- /dev/null +++ b/packages/spec/scripts/lib/schema-section.ts @@ -0,0 +1,229 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * One published schema → the `## SchemaName` section a reference page carries. + * + * Extracted from `build-docs.ts` for the same reason `format-type.ts` was + * (#4912): the generator is a top-level script with side effects, so the only + * way to assert on what a section CONTAINS used to be to run the whole thing + * and grep the emitted `.mdx` — and the defect this module exists to fix was a + * section that rendered as the empty string, which is exactly what grepping + * emitted pages cannot see. #7658 was filed after the shape survived + * unnoticed for months on 45 published schemas. + */ + +import { escapeMdxDescription } from './escape-mdx'; +import { formatPropertyType, formatType, type TypeContext } from './format-type'; + +/** What a section needs from the generator that a unit test can supply. */ +export interface SectionContext { + /** + * Resolve a schema name to its page href, or `null` when the name does not + * identify one — same contract as `TypeContext.schemaHref`, and passed + * straight through to it. + */ + schemaHref?: (name: string) => string | null; +} + +/** + * Which node of the published document IS the schema the section documents. + * + * The four steps are ordered, and the order is load-bearing — each is a + * narrower claim than the next, so a document that satisfies two must be read + * by the first: + * + * 1. `definitions`/`$defs` entry under the schema's own name — the bundled + * spelling, and unambiguous when present. + * 2. The ROOT, when it carries a shape this renderer recognizes. JSON Schema + * 2020-12 puts a single schema's content at root level. + * 3. The first definition entry, for a document whose root is a `$ref` into + * its own `$defs` under some other key. + * 4. The ROOT, unconditionally (#7658). + * + * STEP 4 IS THE FIX, and it is appended rather than folded into step 2 on + * purpose. Step 2's condition (`properties || enum || anyOf || oneOf`) is an + * enumeration of shapes, and a JSON Schema root can be plenty of things that + * are none of them: a bare scalar (`{ type: 'string', description: … }`, what + * `z.string().describe()` compiles to), a record map (`type: 'object'` + + * `additionalProperties`, no `properties`), an array, an `allOf`. Every one of + * those left `mainDef` undefined, and the caller answered that with + * `return ''` — dropping the heading, the schema's own `.describe()` text and + * the whole section, while the page's `## TypeScript Usage` import line (driven + * by the export surface, not by this function) went on naming the export. The + * page read as though it had forgotten to finish rendering an entry it had just + * imported. + * + * Widening step 2 instead would have been the smaller diff and the wrong one: + * a document that reaches step 3 today must keep reaching it, or a root that + * merely LOOKS renderable would start outranking the definition the old code + * chose. Appending leaves every one of the 1533 sections that render today + * byte-identical (measured: regenerating with this change moves only the 45 + * that produced nothing). + * + * Never returns `undefined` now — the document itself is always an answer. + */ +export function selectRootDef(schemaName: string, schema: any): any { + const defs = schema?.definitions || schema?.$defs || {}; + let mainDef = defs[schemaName]; + + if (!mainDef && (schema.properties || schema.enum || schema.anyOf || schema.oneOf)) { + mainDef = schema; + } + + if (!mainDef) { + mainDef = Object.values(defs)[0]; + } + + // The document IS the schema (#7658). See above for why this is last. + if (!mainDef) { + mainDef = schema; + } + + return mainDef; +} + +/** + * Render one schema's section, heading included. + * + * `category` is not a parameter: everything category-scoped reaches this + * through `ctx.schemaHref`, which the generator closes over the rendering + * category with (`schemaHrefFrom`) precisely because a bare name is not a + * schema identity (#4696). + */ +export function renderSchemaSection(schemaName: string, schema: any, ctx: SectionContext = {}): string { + const defs = schema?.definitions || schema?.$defs || {}; + const mainDef = selectRootDef(schemaName, schema); + + let md = ''; + + // Add schema heading + md += `## ${schemaName}\n\n`; + + // Description text is made MDX-safe by `lib/escape-mdx.ts` — extracted so the + // escaping can be pinned directly instead of by grepping emitted `.mdx`. + + // Add description with better formatting + if (mainDef.description) { + md += `${escapeMdxDescription(mainDef.description)}\n\n`; + } + + const typeCtx: TypeContext = { defs, currentSchema: schemaName, schemaHref: ctx.schemaHref }; + + const renderProperties = (props: any, required: Set = new Set()) => { + // Vocabularies too wide for their own table cell. Collected while the + // table is built and printed as `### Allowed Values` bullets right after + // it, so the complete list never leaves the page the cell sits on + // (#6225) — the same rendering a hoisted `type: 'string'` + `enum` schema + // has always got, now reachable from a property position too. + const relocated: Array<{ key: string; members: string[] }> = []; + let t = `### Properties\n\n`; + t += `| Property | Type | Required | Description |\n`; + t += `| :--- | :--- | :--- | :--- |\n`; + for (const [key, prop] of Object.entries(props) as [string, any][]) { + const { cell, allowedValues } = formatPropertyType(prop, typeCtx); + if (allowedValues) relocated.push({ key, members: allowedValues }); + // Backslashes first, then pipes — same order as `desc` below, and for + // the same reason: escaping pipes first lets a literal backslash in + // the input pair with the escape and free the pipe again. + const typeStr = cell + .replace(/\\/g, '\\\\') + .replace(/\|/g, '\\|'); + const isReq = required.has(key) ? '✅' : 'optional'; + // Escape for the GFM table cell last: backslashes first (so an existing + // `\|` in a description can't decay into an escaped backslash + live + // pipe), then pipes — an unescaped `|` (even inside a code span) + // splits the cell. + const desc = escapeMdxDescription((prop.description || '').replace(/\n/g, ' ')) + .replace(/\\/g, '\\\\') + .replace(/\|/g, '\\|'); + t += `| **${key}** | \`${typeStr}\` | ${isReq} | ${desc} |\n`; + } + t += '\n'; + // Qualified by schema AND property: `api/errors.mdx` carries a wide + // `code` on both `EnhancedApiError` and `FieldError`, so a heading naming + // only the property would give one page two identical anchors. + for (const { key, members } of relocated) { + t += `### Allowed Values: \`${schemaName}.${key}\`\n\n`; + t += members.map(m => `* \`${m}\``).join('\n'); + t += `\n\n`; + } + return t; + }; + + if (mainDef.type === 'object' && mainDef.properties) { + md += renderProperties(mainDef.properties, new Set(mainDef.required || [])); + + } else if (mainDef.type === 'string' && mainDef.enum) { + md += `### Allowed Values\n\n`; + md += mainDef.enum.map((e: string) => `* \`${e}\``).join('\n'); + md += `\n\n`; + + } else if (mainDef.anyOf || mainDef.oneOf) { + md += `### Union Options\n\nThis schema accepts one of the following structures:\n\n`; + const variants = mainDef.anyOf || mainDef.oneOf; + variants.forEach((variant: any, index: number) => { + const variantTitle = variant.title || `Option ${index + 1}`; + md += `#### ${variantTitle}\n\n`; + if (variant.description) md += `${escapeMdxDescription(variant.description)}\n\n`; + + if (variant.type === 'object' && variant.properties) { + if (variant.properties.type && variant.properties.type.const) { + md += `**Type:** \`${variant.properties.type.const}\`\n\n`; + } + md += renderProperties(variant.properties, new Set(variant.required || [])); + } else if (variant.enum) { + md += `Allowed Values: ${variant.enum.map((e:string) => `\`${e}\``).join(', ')}\n\n`; + } else if (variant.$ref) { + md += `Reference: ${formatType(variant, typeCtx)}\n\n`; + } else { + md += `Type: \`${formatType(variant, typeCtx)}\`\n\n`; + } + md += `---\n\n`; + }); + + } else { + // Everything the three branches above do not claim: a bare scalar, a record + // map, an array, a `$ref` alias (#7658). Before this branch existed these + // schemas reached the caller's `if (!mainDef) return ''` and lost their + // section whole — description included — so the fix is only half done at + // `selectRootDef`: a heading with nothing under it states no less than the + // empty string did. + // + // ONE LINE, and it is `formatType`'s rendering, not a second one. That is + // what every property row on the page already prints for the same node, so + // `ObjectName`'s own section and a property typed `ObjectName` cannot + // disagree about what it is. It is also why the line does NOT spell + // `pattern` / `minLength` / `minimum`: this renderer has never printed a + // constraint in any position, and inventing constraint prose here alone + // would make a root section say more than the identical property row two + // pages over. The JSON Schema under `json-schema/` stays the authority on + // constraints, exactly as it is for every table cell. + // + // `allOf` is spelled here rather than in `formatType`, because it is a ROOT + // spelling: `z.intersection(...)` / `.and(...)` compiles to it at the top of + // a document (`data/FilterCondition`) and `formatType` has never met one in + // a property position. Reaching it through `formatType` would return the + // node's `type` — absent on an intersection — i.e. `any`, which on this page + // reads as "free-form slot, nothing validates it", the exact misreading + // #5606 fixed for tombstones. `&` is not a new notation either: the object + // branch of `formatType` already joins a declared shape to its open tail + // with it. + // + // Identical member spellings collapse. That is safe HERE and deliberately + // not the union rule: `A & A` is `A`, so a repeat carries nothing, whereas + // #6569's dedupe had to keep counting the variants it withheld because a + // union's ARITY is a fact about what the author must choose between. + // + // `any` is still withheld rather than printed, for a node that is none of + // the above and carries no `type` at all. A heading and the schema's + // description, with no claim about its type, is the honest output. + const rendered = Array.isArray(mainDef.allOf) + ? [...new Set(mainDef.allOf.map((member: any) => formatType(member, typeCtx)))].join(' & ') + : formatType(mainDef, typeCtx); + if (rendered !== 'any') { + md += `**Type:** \`${rendered}\`\n\n`; + } + } + + return md; +} diff --git a/packages/spec/scripts/schema-section.test.ts b/packages/spec/scripts/schema-section.test.ts new file mode 100644 index 0000000000..f0960f9af4 --- /dev/null +++ b/packages/spec/scripts/schema-section.test.ts @@ -0,0 +1,195 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Pin for the reference-docs SECTION renderer — what a `## SchemaName` block + * contains, and the guarantee that it exists at all (#7658). + * + * THE DEFECT THIS PINS. `generateMarkdown` chose which node of a published + * document it was documenting by enumerating shapes — `properties`, `enum`, + * `anyOf`, `oneOf` — and answered "none of those" with `return ''`. A JSON + * Schema root is routinely none of those: a bare scalar is what + * `z.string().describe(…)` compiles to, a record map is `z.record(…)`, and an + * array is `z.array(…)`. Each lost its ENTIRE section — heading, and the + * `.describe()` prose an author wrote to be read — while the page's + * `## TypeScript Usage` line, which is driven by the export surface rather than + * by this function, went on naming the export. 45 published schemas were in + * that state when the issue was measured, 33 of them carrying a description. + * + * WHY THIS FILE EXISTS AT ALL, rather than a grep over emitted `.mdx`. The + * defect's output was the EMPTY STRING. Nothing in `content/docs/references/` + * showed it: `check:docs` compares generated output to committed output, so a + * section that never existed stays green forever, and a `grep` for what is + * missing has nothing to match. The renderer had to leave the side-effecting + * top-level script before it could be asserted on — the same move, for the same + * reason, that #4912 made for `format-type.ts`. + * + * MEASURED (reverse verification): restoring the old behaviour — deleting the + * unconditional root fallback at the end of `selectRootDef` and returning `''` + * when nothing matched — turns 7 of these 13 cases red (the six `#7658` render + * cases, each losing its whole section so that even `## Name` is gone, plus the + * `selectRootDef` fallback pin) and leaves the other 6 green. That asymmetry is + * the point: the four shapes the old code enumerated were always rendered + * correctly, which is exactly why this survived unnoticed. + */ + +import { describe, expect, it } from 'vitest'; + +import { renderSchemaSection, selectRootDef } from './lib/schema-section'; + +/** The `#7658` specimen: `z.string().describe(…)` with no vocabulary. */ +const OPAQUE_STRING = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'string', + description: 'Opaque plan/tier identifier. This schema accepts any string.', +}; + +describe('renderSchemaSection — root shapes the old enumeration missed (#7658)', () => { + it('renders a bare opaque string with its description and type', () => { + const md = renderSchemaSection('TenantPlan', OPAQUE_STRING); + + expect(md).toContain('## TenantPlan'); + // The whole point of the card: the `.describe()` text reaches the page. + expect(md).toContain('Opaque plan/tier identifier. This schema accepts any string.'); + expect(md).toContain('**Type:** `string`'); + }); + + it('renders a bare non-string scalar', () => { + const md = renderSchemaSection('TraceFlags', { + type: 'integer', + minimum: 0, + maximum: 255, + description: 'Trace flags bitmap', + }); + + expect(md).toContain('## TraceFlags'); + expect(md).toContain('Trace flags bitmap'); + expect(md).toContain('**Type:** `integer`'); + }); + + it('renders a record map (no `properties`, so the object branch never claimed it)', () => { + const md = renderSchemaSection('InlineLocaleMap', { + type: 'object', + propertyNames: { type: 'string' }, + additionalProperties: { type: 'string' }, + description: 'Inline locale map: BCP-47 tag → translated string', + }); + + expect(md).toContain('## InlineLocaleMap'); + expect(md).toContain('Inline locale map: BCP-47 tag → translated string'); + expect(md).toContain('**Type:** `Record`'); + }); + + it('renders an array root', () => { + const md = renderSchemaSection('OidcProvidersConfig', { + type: 'array', + items: { type: 'object', properties: { providerId: { type: 'string' } }, required: ['providerId'] }, + description: 'List of OIDC/OAuth2 providers for enterprise SSO.', + }); + + expect(md).toContain('## OidcProvidersConfig'); + expect(md).toContain('**Type:** `{ providerId: string }[]`'); + }); + + it('renders an `allOf` intersection, collapsing repeated member spellings', () => { + const md = renderSchemaSection('FilterCondition', { + allOf: [ + { type: 'object', propertyNames: { type: 'string' }, additionalProperties: {} }, + { type: 'object', propertyNames: { type: 'string' }, additionalProperties: {} }, + { type: 'object', properties: { $not: { $ref: '#' } } }, + ], + }); + + // `A & A` is `A`, so the duplicate member is dropped — unlike a union, where + // the arity is a fact about what the author chooses between (#6569). + expect(md).toContain('**Type:** `Record & { $not?: [FilterCondition](#filtercondition) }`'); + }); + + it('never returns the empty string — a section always has its heading', () => { + // The tail case: a document this renderer can say nothing else about still + // gets its anchor, because the page has already imported the name above. + const md = renderSchemaSection('Unknowable', {}); + + expect(md).toContain('## Unknowable'); + // ...and no `**Type:** \`any\``, which would read as "nothing validates it" + // (the #5606 misreading) rather than "this renderer cannot type it". + expect(md).not.toContain('**Type:**'); + }); +}); + +describe('renderSchemaSection — shapes unchanged by #7658', () => { + it('renders an object as a property table', () => { + const md = renderSchemaSection('Widget', { + type: 'object', + properties: { id: { type: 'string', description: 'The id' } }, + required: ['id'], + description: 'A widget', + }); + + expect(md).toContain('## Widget'); + expect(md).toContain('### Properties'); + expect(md).toContain('| **id** | `string` | ✅ | The id |'); + // The type-specific branch claimed it; no root type line is added on top. + expect(md).not.toContain('**Type:**'); + }); + + it('renders a string vocabulary as Allowed Values bullets', () => { + const md = renderSchemaSection('Status', { type: 'string', enum: ['draft', 'live'] }); + + expect(md).toContain('### Allowed Values'); + expect(md).toContain("* `draft`"); + expect(md).not.toContain('**Type:**'); + }); + + it('renders a union as Union Options', () => { + const md = renderSchemaSection('Either', { + anyOf: [{ type: 'string' }, { type: 'number' }], + }); + + expect(md).toContain('### Union Options'); + expect(md).toContain('Type: `string`'); + }); +}); + +describe('selectRootDef — precedence', () => { + it('prefers the definition named after the schema', () => { + const named = { type: 'string', description: 'from $defs' }; + const picked = selectRootDef('Thing', { + type: 'string', + description: 'from root', + $defs: { Thing: named, Other: { type: 'number' } }, + }); + + expect(picked).toBe(named); + }); + + it('prefers a recognizable ROOT shape over the first definition', () => { + const doc = { + type: 'object', + properties: { a: { type: 'string' } }, + $defs: { Other: { type: 'number' } }, + }; + + expect(selectRootDef('Thing', doc)).toBe(doc); + }); + + /** + * The regression this file most needs to hold, and the reason the #7658 fix + * is APPENDED to `selectRootDef` rather than folded into the root-shape test. + * A document whose root carries no recognizable shape but whose `$defs` holds + * one must keep choosing the definition: widening the root test instead would + * have let a bare `{ description }` root outrank the definition that has the + * actual content, silently rewriting sections that render correctly today. + */ + it('falls back to the first definition before the root', () => { + const other = { type: 'object', properties: { a: { type: 'string' } } }; + const picked = selectRootDef('Thing', { description: 'wrapper', $defs: { Other: other } }); + + expect(picked).toBe(other); + }); + + it('falls back to the document itself when nothing else matches (#7658)', () => { + const doc = { type: 'string', description: 'opaque' }; + + expect(selectRootDef('Thing', doc)).toBe(doc); + }); +});