From cd672dc29ce7a7cba170420d8cae38aa3c359b41 Mon Sep 17 00:00:00 2001 From: javorosas Date: Wed, 21 Oct 2026 17:30:07 +0200 Subject: [PATCH 1/3] perf: prepare API reference presentation during build --- website/docusaurus.config.js | 2 +- website/plugins/api-reference/index.js | 61 ++++++ website/plugins/api-reference/presentation.js | 194 ++++++++++++++++++ website/src/pages/api.tsx | 168 ++------------- 4 files changed, 271 insertions(+), 154 deletions(-) create mode 100644 website/plugins/api-reference/index.js create mode 100644 website/plugins/api-reference/presentation.js diff --git a/website/docusaurus.config.js b/website/docusaurus.config.js index 7ed3b70f9..007e7af48 100644 --- a/website/docusaurus.config.js +++ b/website/docusaurus.config.js @@ -41,7 +41,7 @@ const darkCodeTheme = require("prism-react-renderer").themes.dracula; }), ], [ - "redocusaurus", + require.resolve("./plugins/api-reference"), { specs: [ { diff --git a/website/plugins/api-reference/index.js b/website/plugins/api-reference/index.js new file mode 100644 index 000000000..76c00645b --- /dev/null +++ b/website/plugins/api-reference/index.js @@ -0,0 +1,61 @@ +const { readFile, writeFile } = require("node:fs/promises"); +const path = require("node:path"); +const redocusaurus = require("redocusaurus").default; +const preparePresentation = require("./presentation"); + +module.exports = async function apiReferencePreset(context, options) { + const preset = await redocusaurus(context, options); + return { + ...preset, + plugins: [ + ...preset.plugins.map(([pluginPath, pluginOptions]) => { + const redocPlugin = require(pluginPath); + function presentationPlugin(pluginContext, pluginOptions) { + const plugin = redocPlugin.default(pluginContext, pluginOptions); + return { + ...plugin, + async loadContent() { + const content = await plugin.loadContent(); + return { + ...content, + // Keep the original bundle for downloads; render only this prepared copy. + converted: preparePresentation( + content.converted, + pluginOptions.id.slice(4), + ), + }; + }, + getPathsToWatch() { + return [ + ...plugin.getPathsToWatch(), + require.resolve("./presentation"), + ]; + }, + }; + } + presentationPlugin.validateOptions = redocPlugin.validateOptions; + return [presentationPlugin, pluginOptions]; + }), + function initialReferenceStyles() { + return { + name: "initial-reference-styles", + async postBuild({ outDir }) { + const filename = path.join(outDir, "api/index.html"); + const html = await readFile(filename, "utf8"); + // Redocusaurus emits an invalid dark-mode selector before hydration. + // Remove after upstream fixes https://github.com/rohit-gohri/redocusaurus/issues/215. + await writeFile( + filename, + html.replace(/]*>[\s\S]*?<\/style>/g, (style) => + style.replaceAll( + "html([data-theme='dark'])", + "html[data-theme='dark']", + ), + ), + ); + }, + }; + }, + ], + }; +}; diff --git a/website/plugins/api-reference/presentation.js b/website/plugins/api-reference/presentation.js new file mode 100644 index 000000000..9e4cdfce6 --- /dev/null +++ b/website/plugins/api-reference/presentation.js @@ -0,0 +1,194 @@ +module.exports = function preparePresentation(source, locale) { + const spec = structuredClone(source); + // Presentation labels do not change the downloadable API contract. + spec.components.schemas.InvoiceCustomerInput.oneOf = [ + { + ...spec.components.schemas.CustomerCreateInput, + title: locale === "es" ? "Datos del cliente" : "Customer details", + anyOf: spec.components.schemas.CustomerCreateInput.anyOf.map( + (variant, index) => ({ + ...variant, + title: (locale === "es" + ? ["Nacional", "Extranjero", "Público en general"] + : ["Mexican customer", "Foreign customer", "General public"])[ + index + ], + }), + ), + }, + { + ...spec.components.schemas.InvoiceCustomerInput.oneOf[1], + title: locale === "es" ? "Cliente registrado" : "Registered customer", + }, + ]; + spec.components.schemas.InvoiceCommonInput.allOf[0].properties.customer = { + ...spec.components.schemas.InvoiceCustomerInput, + description: + locale === "es" + ? "Datos del cliente o ID de un cliente registrado." + : "Customer details or the ID of a registered customer.", + }; + spec.components.schemas.InvoiceDraftCommonInput.allOf[1].properties.customer = + { + ...spec.components.schemas.InvoiceCommonInput.allOf[0].properties + .customer, + oneOf: [ + ...spec.components.schemas.InvoiceCustomerInput.oneOf, + { + type: "null", + title: locale === "es" ? "Sin cliente" : "No customer", + }, + ], + }; + for (const customer of [ + spec.components.schemas.ReceiptInput.allOf[0].properties.customer, + spec.components.schemas.ReceiptAssignCustomerInput.properties.customer, + spec.components.schemas.InvoiceReceiptInput.allOf[0].properties.customer, + spec.components.schemas.ToInvoiceInput.properties.customer, + spec.components.schemas.ToInvoicePreviewInput.properties.customer, + spec.components.schemas.RetentionUpdateInput.properties.customer, + ]) { + customer.oneOf = customer.oneOf.map((variant) => { + if (variant.$ref === "#/components/schemas/CustomerCreateInput") { + return spec.components.schemas.InvoiceCustomerInput.oneOf[0]; + } + return { + ...variant, + title: + variant.type === "null" + ? locale === "es" + ? "Sin cliente" + : "No customer" + : locale === "es" + ? "Cliente registrado" + : "Registered customer", + }; + }); + } + for (const name of [ + "InvoiceItemInput", + "InvoiceItemEgresoInput", + "InvoiceItemTrasladoInput", + ]) { + const product = spec.components.schemas[name].properties.product; + product.description = + locale === "es" + ? "Datos del producto o servicio, o ID de un producto registrado." + : "Product or service details, or the ID of a registered product."; + product.oneOf = product.oneOf.map((variant) => ({ + ...variant, + title: + variant.type === "string" + ? locale === "es" + ? "Producto registrado" + : "Registered product" + : locale === "es" + ? "Datos del producto" + : "Product details", + })); + } + // Native oneOf tabs keep the viewing mode separate from request fields. + spec.components.schemas.InvoiceCreateInput.oneOf = + spec.components.schemas.InvoiceCreateInput.oneOf.map((invoiceType) => ({ + ...invoiceType, + oneOf: invoiceType.oneOf.map((variant) => ({ + ...variant, + title: + variant.title === "draft" + ? locale === "es" + ? "Borrador" + : "Draft" + : locale === "es" + ? "Timbrar" + : "Issue", + })), + })); + for (const [name, schema] of Object.entries(spec.components.schemas)) { + if (!name.startsWith("Invoice") || !name.endsWith("Input")) continue; + for (const part of [schema, ...(schema.allOf || [])]) { + if (part.properties?.type) { + part.properties.type.description = + locale === "es" + ? "Código del tipo de factura seleccionado arriba. Selecciona otro tipo para consultar sus campos y código correspondiente." + : "Code for the invoice type selected above. Select another type to see its fields and corresponding code."; + } + } + } + function propertyNames(schema) { + if (schema.$ref) { + return propertyNames( + spec.components.schemas[schema.$ref.split("/").pop()], + ); + } + return [ + ...Object.keys(schema.properties || {}), + ...(schema.allOf || []).flatMap(propertyNames), + ]; + } + const orderedInputs = [ + [ + spec.components.schemas.InvoiceCreateInput.oneOf.flatMap( + (invoiceType) => invoiceType.oneOf, + ), + [ + "customer", + "items", + "payment_form", + "type", + "status", + "payment_method", + "use", + "currency", + "exchange", + "conditions", + "date", + "folio_number", + "series", + "address", + "third_party", + "related_documents", + "global", + "export", + "complements", + "external_id", + "idempotency_key", + "pdf_custom_section", + "addenda", + "namespaces", + "pdf_options", + ], + ], + [ + spec.components.schemas.RetentionInput.oneOf, + [ + "customer", + "cve_retenc", + "periodo", + "totales", + "status", + "fecha_exp", + "desc_retenc", + "folio_int", + "external_id", + "idempotency_key", + "complements", + "pdf_custom_section", + "addenda", + "namespaces", + ], + ], + ]; + for (const [variants, order] of orderedInputs) { + for (const variant of variants) { + const names = new Set(propertyNames(variant)); + // Empty schemas establish insertion order; allOf keeps every field constraint. + variant.properties = Object.fromEntries( + [ + ...order.filter((name) => names.has(name)), + ...[...names].filter((name) => !order.includes(name)), + ].map((name) => [name, variant.properties?.[name] || {}]), + ); + } + } + return spec; +}; diff --git a/website/src/pages/api.tsx b/website/src/pages/api.tsx index d93a741b9..d3e78f675 100644 --- a/website/src/pages/api.tsx +++ b/website/src/pages/api.tsx @@ -1,166 +1,28 @@ -import React, {useMemo} from 'react'; -import ApiDoc from '@theme/ApiDoc'; -import useSpecData from '@theme/useSpecData'; -import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; -import { translate } from '@docusaurus/Translate'; +import React from "react"; +import ApiDoc from "@theme/ApiDoc"; +import useSpecData from "@theme/useSpecData"; +import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; +import { translate } from "@docusaurus/Translate"; + +const presentationOptions = { sortRequiredPropsFirst: false }; function CustomPage() { - const {i18n} = useDocusaurusContext(); - const locale = i18n.currentLocale; - const specData = useSpecData(`api-${locale}`); - const displaySpecData = useMemo(() => { - const spec = structuredClone(specData.spec); - // Presentation labels do not change the downloadable API contract. - spec.components.schemas.InvoiceCustomerInput.oneOf = [ - { - ...spec.components.schemas.CustomerCreateInput, - title: locale === 'es' ? 'Datos del cliente' : 'Customer details', - anyOf: spec.components.schemas.CustomerCreateInput.anyOf.map( - (variant: object, index: number) => ({ - ...variant, - title: (locale === 'es' - ? ['Nacional', 'Extranjero', 'Público en general'] - : ['Mexican customer', 'Foreign customer', 'General public'])[index], - }), - ), - }, - { - ...spec.components.schemas.InvoiceCustomerInput.oneOf[1], - title: locale === 'es' ? 'Cliente registrado' : 'Registered customer', - }, - ]; - spec.components.schemas.InvoiceCommonInput.allOf[0].properties.customer = { - ...spec.components.schemas.InvoiceCustomerInput, - description: locale === 'es' - ? 'Datos del cliente o ID de un cliente registrado.' - : 'Customer details or the ID of a registered customer.', - }; - spec.components.schemas.InvoiceDraftCommonInput.allOf[1].properties.customer = { - ...spec.components.schemas.InvoiceCommonInput.allOf[0].properties.customer, - oneOf: [ - ...spec.components.schemas.InvoiceCustomerInput.oneOf, - { - type: 'null', - title: locale === 'es' ? 'Sin cliente' : 'No customer', - }, - ], - }; - for (const customer of [ - spec.components.schemas.ReceiptInput.allOf[0].properties.customer, - spec.components.schemas.ReceiptAssignCustomerInput.properties.customer, - spec.components.schemas.InvoiceReceiptInput.allOf[0].properties.customer, - spec.components.schemas.ToInvoiceInput.properties.customer, - spec.components.schemas.ToInvoicePreviewInput.properties.customer, - spec.components.schemas.RetentionUpdateInput.properties.customer, - ]) { - customer.oneOf = customer.oneOf.map((variant: {$ref?: string; type?: string}) => { - if (variant.$ref === '#/components/schemas/CustomerCreateInput') { - return spec.components.schemas.InvoiceCustomerInput.oneOf[0]; - } - return { - ...variant, - title: variant.type === 'null' - ? (locale === 'es' ? 'Sin cliente' : 'No customer') - : (locale === 'es' ? 'Cliente registrado' : 'Registered customer'), - }; - }); - } - for (const name of ['InvoiceItemInput', 'InvoiceItemEgresoInput', 'InvoiceItemTrasladoInput']) { - const product = spec.components.schemas[name].properties.product; - product.description = locale === 'es' - ? 'Datos del producto o servicio, o ID de un producto registrado.' - : 'Product or service details, or the ID of a registered product.'; - product.oneOf = product.oneOf.map((variant: {$ref?: string; type?: string}) => ({ - ...variant, - title: variant.type === 'string' - ? (locale === 'es' ? 'Producto registrado' : 'Registered product') - : (locale === 'es' ? 'Datos del producto' : 'Product details'), - })); - } - // Native oneOf tabs keep the viewing mode separate from request fields. - spec.components.schemas.InvoiceCreateInput.oneOf = - spec.components.schemas.InvoiceCreateInput.oneOf.map( - (invoiceType: {oneOf: {title: string}[]}) => ({ - ...invoiceType, - oneOf: invoiceType.oneOf.map((variant) => ({ - ...variant, - title: variant.title === 'draft' - ? (locale === 'es' ? 'Borrador' : 'Draft') - : (locale === 'es' ? 'Timbrar' : 'Issue'), - })), - }), - ); - for (const [name, schema] of Object.entries<{allOf?: {properties?: {type?: {description: string}}}[]; properties?: {type?: {description: string}}}>(spec.components.schemas)) { - if (!name.startsWith('Invoice') || !name.endsWith('Input')) continue; - for (const part of [schema, ...(schema.allOf || [])]) { - if (part.properties?.type) { - part.properties.type.description = locale === 'es' - ? 'Código del tipo de factura seleccionado arriba. Selecciona otro tipo para consultar sus campos y código correspondiente.' - : 'Code for the invoice type selected above. Select another type to see its fields and corresponding code.'; - } - } - } - type OrderedSchema = { - $ref?: string; - allOf?: OrderedSchema[]; - properties?: Record; - }; - function propertyNames(schema: OrderedSchema): string[] { - if (schema.$ref) { - return propertyNames(spec.components.schemas[schema.$ref.split('/').pop()!]); - } - return [ - ...Object.keys(schema.properties || {}), - ...(schema.allOf || []).flatMap(propertyNames), - ]; - } - const orderedInputs: [OrderedSchema[], string[]][] = [ - [ - spec.components.schemas.InvoiceCreateInput.oneOf.flatMap( - (invoiceType: {oneOf: OrderedSchema[]}) => invoiceType.oneOf, - ), - ['customer', 'items', 'payment_form', 'type', 'status', 'payment_method', - 'use', 'currency', 'exchange', 'conditions', 'date', 'folio_number', - 'series', 'address', 'third_party', 'related_documents', 'global', - 'export', 'complements', 'external_id', 'idempotency_key', - 'pdf_custom_section', 'addenda', 'namespaces', 'pdf_options'], - ], - [ - spec.components.schemas.RetentionInput.oneOf, - ['customer', 'cve_retenc', 'periodo', 'totales', 'status', 'fecha_exp', - 'desc_retenc', 'folio_int', 'external_id', 'idempotency_key', - 'complements', 'pdf_custom_section', 'addenda', 'namespaces'], - ], - ]; - for (const [variants, order] of orderedInputs) { - for (const variant of variants) { - const names = new Set(propertyNames(variant)); - // Empty schemas establish insertion order; allOf keeps every field constraint. - variant.properties = Object.fromEntries([ - ...order.filter((name) => names.has(name)), - ...[...names].filter((name) => !order.includes(name)), - ].map((name) => [name, variant.properties?.[name] || {}])); - } - } - return { - ...specData, - spec, - optionsOverrides: {sortRequiredPropsFirst: false}, - }; - }, [specData, locale]); + const { i18n } = useDocusaurusContext(); + const specData = useSpecData(`api-${i18n.currentLocale}`); return ( ); } From c514ba1373dd5d607d2a993dbd5cb53cb709615a Mon Sep 17 00:00:00 2001 From: javorosas Date: Wed, 21 Oct 2026 17:42:33 +0200 Subject: [PATCH 2/3] fix: preserve dark reference styles before hydration --- website/package.json | 1 + website/plugins/api-reference/index.js | 14 ++--- .../plugins/api-reference/initial-styles.js | 51 +++++++++++++++++++ website/pnpm-lock.yaml | 3 ++ 4 files changed, 63 insertions(+), 6 deletions(-) create mode 100644 website/plugins/api-reference/initial-styles.js diff --git a/website/package.json b/website/package.json index f793076ba..f90a2e6dc 100644 --- a/website/package.json +++ b/website/package.json @@ -39,6 +39,7 @@ "ajv": "^8.20.0", "js-yaml": "5.4.2", "openapi-typescript": "7.13.0", + "postcss": "8.5.15", "typescript": "^5.8.3" }, "browserslist": { diff --git a/website/plugins/api-reference/index.js b/website/plugins/api-reference/index.js index 76c00645b..8b2cfa2f7 100644 --- a/website/plugins/api-reference/index.js +++ b/website/plugins/api-reference/index.js @@ -2,6 +2,7 @@ const { readFile, writeFile } = require("node:fs/promises"); const path = require("node:path"); const redocusaurus = require("redocusaurus").default; const preparePresentation = require("./presentation"); +const initialStyles = require("./initial-styles"); module.exports = async function apiReferencePreset(context, options) { const preset = await redocusaurus(context, options); @@ -42,15 +43,16 @@ module.exports = async function apiReferencePreset(context, options) { async postBuild({ outDir }) { const filename = path.join(outDir, "api/index.html"); const html = await readFile(filename, "utf8"); - // Redocusaurus emits an invalid dark-mode selector before hydration. + // Match initial server classes as well as hydrated dark-theme classes. // Remove after upstream fixes https://github.com/rohit-gohri/redocusaurus/issues/215. await writeFile( filename, - html.replace(/]*>[\s\S]*?<\/style>/g, (style) => - style.replaceAll( - "html([data-theme='dark'])", - "html[data-theme='dark']", - ), + html.replace( + /(]*>)([\s\S]*?)(<\/style>)/g, + (style, start, css, end) => + css.includes("data-styled.g") + ? start + initialStyles(css) + end + : style, ), ); }, diff --git a/website/plugins/api-reference/initial-styles.js b/website/plugins/api-reference/initial-styles.js new file mode 100644 index 000000000..dd6353fd0 --- /dev/null +++ b/website/plugins/api-reference/initial-styles.js @@ -0,0 +1,51 @@ +const assert = require("node:assert/strict"); +const postcss = require("postcss"); + +module.exports = function initialStyles(css) { + const root = postcss.parse( + css.replaceAll("html([data-theme='dark'])", "html[data-theme='dark']"), + ); + const light = new Map(); + const dark = new Map(); + // styled-components records each component's generated classes in its SSR CSS. + root.walkRules((rule) => { + if (!rule.selector.includes("data-styled.g")) return; + const id = rule.selector.match(/\[id="([^"]+)"\]/)?.[1]; + const content = rule.nodes.find((node) => node.prop === "content")?.value; + assert(id && content, "Unrecognized styled-components SSR metadata"); + const classes = JSON.parse(content).split(",").filter(Boolean); + (rule.selector.startsWith("html:not(") ? light : dark).set(id, classes); + }); + assert( + light.size && light.size === dark.size, + "SSR theme component sets differ", + ); + const aliases = new Map(); + for (const [id, classes] of light) { + assert.equal( + classes.length, + dark.get(id)?.length, + `SSR theme variants differ for ${id}; review initial styles before publishing`, + ); + classes.forEach((lightClass, index) => { + const darkClass = dark.get(id)[index]; + if (lightClass === darkClass) return; + assert( + !aliases.has(darkClass) || aliases.get(darkClass) === lightClass, + "Ambiguous SSR theme class mapping", + ); + aliases.set(darkClass, lightClass); + }); + } + root.walkRules((rule) => { + if (!rule.selector.startsWith("html[data-theme='dark']")) return; + // Match both the server's light-theme classes and the hydrated dark classes. + // :is() keeps the original specificity and leaves the original class supported. + rule.selector = rule.selector.replace( + /\.([a-zA-Z_][\w-]*)/g, + (selector, name) => + aliases.has(name) ? `:is(${selector},.${aliases.get(name)})` : selector, + ); + }); + return root.toString(); +}; diff --git a/website/pnpm-lock.yaml b/website/pnpm-lock.yaml index d3e5f98c9..7233ebb0b 100644 --- a/website/pnpm-lock.yaml +++ b/website/pnpm-lock.yaml @@ -75,6 +75,9 @@ importers: openapi-typescript: specifier: 7.13.0 version: 7.13.0(typescript@5.9.3) + postcss: + specifier: 8.5.15 + version: 8.5.15 typescript: specifier: ^5.8.3 version: 5.9.3 From af669f4528b509c8ccc58a7921d630815843f3f8 Mon Sep 17 00:00:00 2001 From: javorosas Date: Wed, 21 Oct 2026 19:27:56 +0200 Subject: [PATCH 3/3] fix: restore required-first ordering within nested objects --- website/plugins/api-reference/presentation.js | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/website/plugins/api-reference/presentation.js b/website/plugins/api-reference/presentation.js index 9e4cdfce6..d9f7b65f2 100644 --- a/website/plugins/api-reference/presentation.js +++ b/website/plugins/api-reference/presentation.js @@ -125,6 +125,57 @@ module.exports = function preparePresentation(source, locale) { ...(schema.allOf || []).flatMap(propertyNames), ]; } + function requiredNames(schema) { + if (schema.$ref) { + return requiredNames( + spec.components.schemas[schema.$ref.split("/").pop()], + ); + } + return [ + ...(schema.required || []), + ...(schema.allOf || []).flatMap(requiredNames), + ]; + } + const visited = new Set(); + function orderRequiredProperties(schema) { + if (!schema || typeof schema !== "object" || visited.has(schema)) return; + visited.add(schema); + // Referenced components are ordered separately; preserve reference siblings. + if (schema.$ref) return; + for (const child of [ + ...Object.values(schema.properties || {}), + ...(schema.allOf || []), + ...(schema.oneOf || []), + ...(schema.anyOf || []), + schema.items, + schema.additionalProperties, + ]) + orderRequiredProperties(child); + const required = new Set(schema.required || []); + if (!schema.properties || !required.size) return; + schema.properties = Object.fromEntries( + Object.entries(schema.properties).sort( + ([a], [b]) => Number(required.has(b)) - Number(required.has(a)), + ), + ); + } + for (const schema of Object.values(spec.components.schemas)) { + orderRequiredProperties(schema); + } + // Customer alternatives compose shared fields and required lists with allOf. + for (const name of [ + "CustomerNationalCreateInput", + "CustomerForeignCreateInput", + "CustomerGenericCreateInput", + ]) { + const schema = spec.components.schemas[name]; + const required = new Set(requiredNames(schema)); + schema.properties = Object.fromEntries( + [...new Set(propertyNames(schema))] + .sort((a, b) => Number(required.has(b)) - Number(required.has(a))) + .map((field) => [field, schema.properties?.[field] || {}]), + ); + } const orderedInputs = [ [ spec.components.schemas.InvoiceCreateInput.oneOf.flatMap(