From ae4ad28b35a41513106626d9da1ed1815fc783fb Mon Sep 17 00:00:00 2001 From: ernestognw Date: Thu, 28 May 2026 22:56:30 -0600 Subject: [PATCH] docgen: support hardhat3-style branches in injectTemplates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The injectTemplates pipeline assumed a Hardhat 2 / solidity-docgen layout (docs/config.js + docs/templates-md/). The hardhat3 branch of openzeppelin-contracts replaces solidity-docgen with an in-tree plugin that reads docs/config.mjs and templates from docs/templates/, emits .adoc by default, and loads helpers/properties only from helpers.ts/ properties.ts via ESM dynamic import — so the previous injection was silently ignored and AsciiDoc landed in content/, breaking every guide xref into the API. Detect the layout from the cloned repo and route accordingly: - New layout: overwrite docs/config.mjs with our canonical config (forces pageExtension '.mdx' and templates path 'docs/templates'), wipe and re-populate docs/templates/, rename helpers.js / properties.js to .cjs (Node treats .js as ESM under "type": "module" on hardhat3), patch the internal require, and emit thin ESM shim helpers.ts / properties.ts so the in-tree plugin's loader picks up named exports. - Legacy layout: unchanged behaviour. Rename the kebab-case Handlebars helpers (oz-version, has-functions, typed-params, …) to camelCase across the templates and helpers/ properties modules. ESM named exports cannot carry hyphens, and Handlebars is name-agnostic, so this is transparent to solidity-docgen on older tags. Verified by running against the hardhat3 branch locally: 200 → 1 broken links in next-validate-link, and the one residual is a pre-existing EIP712 NatSpec xref to //learn/upgrading-smart-contracts that also fails on current main's content/contracts/5.x. Co-Authored-By: Claude Opus 4.7 (1M context) --- docgen/config-md.mjs | 20 ++++++ docgen/templates-md/contract.hbs | 30 ++++---- docgen/templates-md/helpers.js | 18 ++--- docgen/templates-md/page.hbs | 6 +- docgen/templates-md/properties.js | 14 ++-- scripts/generate-api-docs.js | 110 ++++++++++++++++++++++++++---- 6 files changed, 152 insertions(+), 46 deletions(-) create mode 100644 docgen/config-md.mjs diff --git a/docgen/config-md.mjs b/docgen/config-md.mjs new file mode 100644 index 00000000..b5789aaf --- /dev/null +++ b/docgen/config-md.mjs @@ -0,0 +1,20 @@ +import path from 'path'; +import fs from 'fs'; + +/** @type import('solidity-docgen/dist/config').UserConfig */ +export default { + outputDir: 'docs/modules/api/pages', + templates: 'docs/templates', + exclude: ['mocks'], + pageExtension: '.mdx', + pages: (_, file, config) => { + const sourcesDir = path.resolve(config.root, config.sourcesDir); + let dir = path.resolve(config.root, file.absolutePath); + while (dir.startsWith(sourcesDir)) { + dir = path.dirname(dir); + if (fs.existsSync(path.join(dir, 'README.adoc'))) { + return path.relative(sourcesDir, dir) + config.pageExtension; + } + } + }, +}; diff --git a/docgen/templates-md/contract.hbs b/docgen/templates-md/contract.hbs index 1e5112b8..d99a9158 100644 --- a/docgen/templates-md/contract.hbs +++ b/docgen/templates-md/contract.hbs @@ -1,10 +1,10 @@ -{{reset-function-counts}} +{{resetFunctionCounts}}
## `{{{name}}}` - + @@ -14,7 +14,7 @@ import "@openzeppelin/{{__item_context.file.absolutePath}}"; ``` -{{{process-natspec natspec.dev}}} +{{{processNatspec natspec.dev}}} {{#if modifiers}}
@@ -28,11 +28,11 @@ import "@openzeppelin/{{__item_context.file.absolutePath}}"; {{/if}} -{{#if has-functions}} +{{#if hasFunctions}}

Functions

-{{#each inherited-functions}} +{{#each inheritedFunctions}} {{#if @first}} {{#each functions}} - [{{{name}}}({{names params}})](#{{anchor}}) @@ -54,7 +54,7 @@ import "@openzeppelin/{{__item_context.file.absolutePath}}";
{{/if}} -{{#if has-events}} +{{#if hasEvents}}

Events

@@ -80,7 +80,7 @@ import "@openzeppelin/{{__item_context.file.absolutePath}}";
{{/if}} -{{#if has-errors}} +{{#if hasErrors}}

Errors

@@ -111,7 +111,7 @@ import "@openzeppelin/{{__item_context.file.absolutePath}}";
-

{{{name}}}({{typed-params params}})

+

{{{name}}}({{typedParams params}})

{{visibility}}

# @@ -120,7 +120,7 @@ import "@openzeppelin/{{__item_context.file.absolutePath}}";
-{{{process-natspec natspec.dev}}} +{{{processNatspec natspec.dev}}}
@@ -132,7 +132,7 @@ import "@openzeppelin/{{__item_context.file.absolutePath}}";
-

{{{name}}}({{typed-params params}}){{#if returns2}} → {{typed-params returns2}}{{/if}}

+

{{{name}}}({{typedParams params}}){{#if returns2}} → {{typedParams returns2}}{{/if}}

{{visibility}}

# @@ -140,7 +140,7 @@ import "@openzeppelin/{{__item_context.file.absolutePath}}";
-{{{process-natspec natspec.dev}}} +{{{processNatspec natspec.dev}}}
@@ -152,7 +152,7 @@ import "@openzeppelin/{{__item_context.file.absolutePath}}";
-

{{{name}}}({{typed-params params}})

+

{{{name}}}({{typedParams params}})

event

# @@ -161,7 +161,7 @@ import "@openzeppelin/{{__item_context.file.absolutePath}}";
-{{{process-natspec natspec.dev}}} +{{{processNatspec natspec.dev}}}
@@ -172,7 +172,7 @@ import "@openzeppelin/{{__item_context.file.absolutePath}}";
-

{{{name}}}({{typed-params params}})

+

{{{name}}}({{typedParams params}})

error

# @@ -180,7 +180,7 @@ import "@openzeppelin/{{__item_context.file.absolutePath}}";
-{{{process-natspec natspec.dev}}} +{{{processNatspec natspec.dev}}}
diff --git a/docgen/templates-md/helpers.js b/docgen/templates-md/helpers.js index 1c679f5f..bc27ee87 100644 --- a/docgen/templates-md/helpers.js +++ b/docgen/templates-md/helpers.js @@ -6,9 +6,9 @@ const os = require('os'); const API_DOCS_PATH = 'contracts/5.x/api'; -module.exports['oz-version'] = () => version; +module.exports.ozVersion = () => version; -module.exports['readme-path'] = opts => { +module.exports.readmePath = opts => { const pageId = opts.data.root.id; const basePath = pageId.replace(/\.(adoc|mdx)$/, ''); return 'contracts/' + basePath + '/README.adoc'; @@ -31,7 +31,7 @@ module.exports.names = params => params?.map(p => p.name).join(', '); // Simple function counter for unique IDs const functionNameCounts = {}; -module.exports['simple-id'] = function (name) { +module.exports.simpleId = function (name) { if (!functionNameCounts[name]) { functionNameCounts[name] = 1; return name; @@ -41,7 +41,7 @@ module.exports['simple-id'] = function (name) { } }; -module.exports['reset-function-counts'] = function () { +module.exports.resetFunctionCounts = function () { Object.keys(functionNameCounts).forEach(key => delete functionNameCounts[key]); return ''; }; @@ -52,13 +52,13 @@ module.exports.eq = (a, b) => a === b; // import specifier for non-`contracts` packages, where the file is published // at the package root (e.g. @openzeppelin/community-contracts/account/X.sol) // but the source lives at contracts/account/X.sol in the repo. -module.exports['strip-contracts-prefix'] = function (p) { +module.exports.stripContractsPrefix = function (p) { return typeof p === 'string' ? p.replace(/^contracts\//, '') : p; }; -module.exports['starts-with'] = (str, prefix) => str && str.startsWith(prefix); +module.exports.startsWith = (str, prefix) => str && str.startsWith(prefix); // Process natspec content with {REF} and link replacement -module.exports['process-natspec'] = function (natspec, opts) { +module.exports.processNatspec = function (natspec, opts) { if (!natspec) return ''; const currentPage = opts.data.root.__item_context?.page || opts.data.root.id; @@ -68,7 +68,7 @@ module.exports['process-natspec'] = function (natspec, opts) { return processCallouts(processed); // Add callout processing at the end }; -module.exports['typed-params'] = params => { +module.exports.typedParams = params => { return params?.map(p => `${p.type}${p.indexed ? ' indexed' : ''}${p.name ? ' ' + p.name : ''}`).join(', '); }; @@ -497,7 +497,7 @@ module.exports.description = opts => { return `Smart contract ${dirName.replace('-', ' ')} utilities and implementations`; }; -module.exports['with-prelude'] = opts => { +module.exports.withPrelude = opts => { const currentPage = opts.data.root.id; const links = getAllLinks(opts.data.site.items, currentPage); const contents = opts.fn(); diff --git a/docgen/templates-md/page.hbs b/docgen/templates-md/page.hbs index 11c79513..70f1155b 100644 --- a/docgen/templates-md/page.hbs +++ b/docgen/templates-md/page.hbs @@ -3,9 +3,9 @@ title: "{{title}}" description: "{{description}}" --- -{{#with-prelude}} -{{readme (readme-path)}} -{{/with-prelude}} +{{#withPrelude}} +{{readme (readmePath)}} +{{/withPrelude}} {{#each items}} {{>contract}} diff --git a/docgen/templates-md/properties.js b/docgen/templates-md/properties.js index f2453b63..1b6e4491 100644 --- a/docgen/templates-md/properties.js +++ b/docgen/templates-md/properties.js @@ -44,24 +44,24 @@ module.exports.inheritance = function ({ item, build }) { .filter((c, i) => c.name !== 'Context' || i === 0); }; -module.exports['has-functions'] = function ({ item }) { +module.exports.hasFunctions = function ({ item }) { return item.inheritance && item.inheritance.some(c => c.functions.length > 0); }; -module.exports['has-events'] = function ({ item }) { +module.exports.hasEvents = function ({ item }) { return item.inheritance && item.inheritance.some(c => c.events.length > 0); }; -module.exports['has-errors'] = function ({ item }) { +module.exports.hasErrors = function ({ item }) { return item.inheritance && item.inheritance.some(c => c.errors.length > 0); }; -module.exports['internal-variables'] = function ({ item }) { +module.exports.internalVariables = function ({ item }) { return item.variables ? item.variables.filter(({ visibility }) => visibility === 'internal') : []; }; -module.exports['has-internal-variables'] = function ({ item }) { - return module.exports['internal-variables']({ item }).length > 0; +module.exports.hasInternalVariables = function ({ item }) { + return module.exports.internalVariables({ item }).length > 0; }; module.exports.functions = function ({ item }) { @@ -79,7 +79,7 @@ module.exports.returns2 = function ({ item }) { } }; -module.exports['inherited-functions'] = function ({ item }) { +module.exports.inheritedFunctions = function ({ item }) { const { inheritance } = item; if (!inheritance) return []; diff --git a/scripts/generate-api-docs.js b/scripts/generate-api-docs.js index b4bfff0b..7b716871 100755 --- a/scripts/generate-api-docs.js +++ b/scripts/generate-api-docs.js @@ -126,6 +126,32 @@ function derivePackageName(repoName) { return withoutPrefix; } +// Helpers and properties exported by templates-md/{helpers,properties}.js, +// used to generate ESM shim files when injecting into the new layout. The +// hardhat3-style docgen plugin only loads helpers.ts/properties.ts via +// `await import()`, so it needs real named exports — kebab-case CJS keys +// don't survive ESM interop. +const HELPER_EXPORTS = [ + "ozVersion", "readmePath", "readme", "names", "simpleId", + "resetFunctionCounts", "eq", "stripContractsPrefix", "startsWith", + "processNatspec", "typedParams", "slug", "title", "description", + "withPrelude", +]; +const PROPERTY_EXPORTS = [ + "anchor", "fullname", "inheritance", "hasFunctions", "hasEvents", + "hasErrors", "internalVariables", "hasInternalVariables", "functions", + "returns2", "inheritedFunctions", +]; + +function tsShim(varName, sourcePath, exports) { + return [ + `import ${varName} from '${sourcePath}';`, + "", + ...exports.map(e => `export const ${e} = ${varName}.${e};`), + "", + ].join("\n"); +} + async function injectTemplates(tempDir, options) { const { contractsRepo, contractsBranch, apiOutputDir } = options; const repoInfo = extractRepoInfo(contractsRepo); @@ -133,24 +159,84 @@ async function injectTemplates(tempDir, options) { console.log("📋 Injecting canonical MDX templates..."); - const templatesTarget = path.join(tempDir, "docs", "templates-md"); - // Overwrite the cloned repo's docs/config.js with our canonical config. - // The cloned repo's hardhat config and prepare-docs.sh scripts both load - // `./docs/config`, so this single overwrite covers both paths without - // needing a separate config-md.js write or a hardhat-config regex patch. - const configTarget = path.join(tempDir, "docs", "config.js"); + // Detect new layout (hardhat3-style): docs/config.mjs + docs/templates/. + // Legacy layout uses docs/config.js + docs/templates-md/. + let newLayout = false; + try { + await fs.access(path.join(tempDir, "docs", "config.mjs")); + newLayout = true; + } catch {} + + const templatesTarget = path.join( + tempDir, + "docs", + newLayout ? "templates" : "templates-md", + ); + const configTarget = path.join( + tempDir, + "docs", + newLayout ? "config.mjs" : "config.js", + ); + const configSource = path.join( + DOCGEN_DIR, + newLayout ? "config-md.mjs" : "config-md.js", + ); + if (newLayout) { + console.log(" ✓ Detected new layout (config.mjs + docs/templates/)"); + } + + // Wipe target templates dir to avoid stale files from the cloned repo + // (e.g. the hardhat3 branch's helpers.ts/properties.ts/contract.hbs). + await fs.rm(templatesTarget, { recursive: true, force: true }); await fs.mkdir(templatesTarget, { recursive: true }); await copyDirRecursive( path.join(DOCGEN_DIR, "templates-md"), templatesTarget, ); - await fs.copyFile(path.join(DOCGEN_DIR, "config-md.js"), configTarget); + await fs.copyFile(configSource, configTarget); + + // The new plugin loads helpers via `require.resolve(dir + '/helpers.ts')` + // and `await import(...)` — only `.ts` is searched and only ESM named + // function exports are picked up. Rename our CJS .js files to .cjs and + // emit thin .ts shims that re-export each function. + const helpersFileName = newLayout ? "helpers.cjs" : "helpers.js"; + const propertiesFileName = newLayout ? "properties.cjs" : "properties.js"; + if (newLayout) { + await fs.rename( + path.join(templatesTarget, "helpers.js"), + path.join(templatesTarget, helpersFileName), + ); + await fs.rename( + path.join(templatesTarget, "properties.js"), + path.join(templatesTarget, propertiesFileName), + ); + // `require('./helpers')` in properties.cjs no longer resolves once + // the file is renamed (Node's CJS resolver does not search for + // `.cjs` by default). Patch the import to use the explicit name. + const propertiesCjsPath = path.join(templatesTarget, propertiesFileName); + let propertiesSource = await fs.readFile(propertiesCjsPath, "utf8"); + propertiesSource = propertiesSource.replace( + /require\(['"]\.\/helpers['"]\)/g, + "require('./helpers.cjs')", + ); + await fs.writeFile(propertiesCjsPath, propertiesSource, "utf8"); + await fs.writeFile( + path.join(templatesTarget, "helpers.ts"), + tsShim("h", "./helpers.cjs", HELPER_EXPORTS), + "utf8", + ); + await fs.writeFile( + path.join(templatesTarget, "properties.ts"), + tsShim("p", "./properties.cjs", PROPERTY_EXPORTS), + "utf8", + ); + } - // Customize API_DOCS_PATH in helpers.js (URL path = file path minus content/) + // Customize API_DOCS_PATH in helpers (URL path = file path minus content/) const apiDocsPath = apiOutputDir.replace(/^content\//, ""); - const helpersPath = path.join(templatesTarget, "helpers.js"); + const helpersPath = path.join(templatesTarget, helpersFileName); let helpers = await fs.readFile(helpersPath, "utf8"); helpers = helpers.replace( /const API_DOCS_PATH = ['"][^'"]+['"]/, @@ -173,7 +259,7 @@ async function injectTemplates(tempDir, options) { // /blob/v0.0.1/... when community-contracts package.json has a placeholder // version that doesn't correspond to an actual tag. contract = contract.replace( - /blob\/v\{\{oz-version\}\}/g, + /blob\/v\{\{ozVersion\}\}/g, `blob/${contractsBranch}`, ); @@ -182,11 +268,11 @@ async function injectTemplates(tempDir, options) { // (npm package is `@openzeppelin/contracts`, file path keeps the prefix). // For other packages (community-contracts, confidential-contracts), the // npm package is published with `contracts/` as the package root, so the - // import skips that prefix — strip it via the strip-contracts-prefix helper. + // import skips that prefix via the stripContractsPrefix helper. if (packageName !== "contracts") { contract = contract.replace( /import "@openzeppelin\/\{\{__item_context\.file\.absolutePath\}\}";/g, - `import "@openzeppelin/${packageName}/{{strip-contracts-prefix __item_context.file.absolutePath}}";`, + `import "@openzeppelin/${packageName}/{{stripContractsPrefix __item_context.file.absolutePath}}";`, ); } await fs.writeFile(contractPath, contract, "utf8");