Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion website/docusaurus.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ const darkCodeTheme = require("prism-react-renderer").themes.dracula;
}),
],
[
"redocusaurus",
require.resolve("./plugins/api-reference"),
{
specs: [
{
Expand Down
1 change: 1 addition & 0 deletions website/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
63 changes: 63 additions & 0 deletions website/plugins/api-reference/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
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);
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");
// 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(
/(<style\b[^>]*>)([\s\S]*?)(<\/style>)/g,
(style, start, css, end) =>
css.includes("data-styled.g")
? start + initialStyles(css) + end
: style,
),
);
},
};
},
],
};
};
51 changes: 51 additions & 0 deletions website/plugins/api-reference/initial-styles.js
Original file line number Diff line number Diff line change
@@ -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();
};
245 changes: 245 additions & 0 deletions website/plugins/api-reference/presentation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
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),
];
}
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(
(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;
};
3 changes: 3 additions & 0 deletions website/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading