diff --git a/README.md b/README.md index 6d807ddd0..278d3478d 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,3 @@ -# aegir - [![ipfs.tech](https://img.shields.io/badge/project-IPFS-blue.svg?style=flat-square)](https://ipfs.tech) [![Discuss](https://img.shields.io/discourse/https/discuss.ipfs.tech/posts.svg?style=flat-square)](https://discuss.ipfs.tech) [![codecov](https://img.shields.io/codecov/c/github/ipfs/aegir.svg?style=flat-square)](https://codecov.io/gh/ipfs/aegir) @@ -7,21 +5,6 @@ > JavaScript project management -## Table of contents - -- [Install](#install) - - [Browser ` - \`\`\` +\`\`\`html + +\`\`\` ` const scripts = pkg.scripts ?? {} diff --git a/src/check-project/readme/structure.js b/src/check-project/readme/structure.js index c83af9585..2525ebc0c 100644 --- a/src/check-project/readme/structure.js +++ b/src/check-project/readme/structure.js @@ -18,7 +18,7 @@ export const STRUCTURE = (monorepoDir, projectDirs) => { } return ` -## Structure +## Packages ${Object.entries(packages).map(([key, value]) => { return `* [\`${key}\`](.${key}) ${value}` diff --git a/src/docs.js b/src/docs.js index aa79cc516..cef3941a9 100644 --- a/src/docs.js +++ b/src/docs.js @@ -54,6 +54,8 @@ const docs = async (ctx, task) => { fromAegir('src/docs/type-indexer-plugin.js'), '--plugin', 'typedoc-plugin-mdn-links', + '--plugin', + fromAegir('src/docs/readme-updater-plugin.js'), ...forwardOptions ], { diff --git a/src/docs/readme-updater-plugin.js b/src/docs/readme-updater-plugin.js new file mode 100644 index 000000000..a2e6e999c --- /dev/null +++ b/src/docs/readme-updater-plugin.js @@ -0,0 +1,197 @@ +import fs from 'node:fs' +import path from 'node:path' +import * as td from 'typedoc' +import { parseMarkdown, writeMarkdown } from '../check-project/readme/utils.js' +import { isMonorepoParent, parseProjects, pkg } from '../utils.js' + +/** + * A plugin that updates the `About` section of a README with the rendered + * contents of a `@packageDocumentation` tag. + * + * @param {td.Application} app + */ +export function load (app) { + /** @type {Record} */ + let projects = {} + + if (isMonorepoParent) { + projects = parseProjects(process.cwd(), pkg.workspaces) + } + + // when rendering has finished, work out which UrlMappings refer to the index + // pages of the current module or monorepo packages + app.renderer.on(td.RendererEvent.END, (/** @type {td.RendererEvent} */ evt) => { + const urlMappings = evt.urls?.filter(urlMapping => { + // skip anything without a model-level comment + if (urlMapping.model?.comment == null) { + return false + } + + // single-module repo + if (urlMapping.url === 'modules.html') { + return true + } + + // mono-repo and the model name matches a package name + if (isMonorepoParent && projects[urlMapping.model.name] != null) { + return true + } + + return false + }).map(urlMapping => { + if (urlMapping.model?.comment == null) { + throw new Error('Model comment was null') + } + + if (isMonorepoParent) { + return { + comment: urlMapping.model.comment, + manifestPath: path.join(projects[urlMapping.model.name].dir, 'package.json'), + readmePath: path.join(projects[urlMapping.model.name].dir, 'README.md') + } + } + + return { + comment: urlMapping.model.comment, + manifestPath: path.join(process.cwd(), 'package.json'), + readmePath: path.join(process.cwd(), 'README.md') + } + }) + + urlMappings?.forEach(urlMapping => updateModule(urlMapping.comment, urlMapping.manifestPath, urlMapping.readmePath, app)) + }) +} + +/** + * Turn the comment into markdown + * + * @param {td.Models.Comment} comment + * @param {string} manifestPath + * @param {string} readmePath + * @param {td.Application} app + * @returns {void} + */ +function updateModule (comment, manifestPath, readmePath, app) { + const about = ` +## About + +${ + comment.summary + .map(item => item.text) + .join('') +} +${ + comment.blockTags + .map(item => { + if (item.tag === '@example') { + return ` +### Example + +${ + item.content + .map(item => item.text) + .join('') +} +` + } + + app.logger.warn(`Unknown block tag: ${item.tag}`) + + return '' + }) +} +`.trim() + + try { + updateReadme(about, manifestPath, readmePath, app) + } catch (/** @type {any} */ err) { + app.logger.error(`Could not update README about section: ${err.stack}`) + } +} + +/** + * Accept a markdown string and add it to the README, replacing any existing + * "About" section + * + * @param {string} aboutMd + * @param {string} manifestPath + * @param {string} readmePath + * @param {td.Application} app + * @returns {void} + */ +function updateReadme (aboutMd, manifestPath, readmePath, app) { + if (!fs.existsSync(readmePath)) { + app.logger.error(`Could not read readme from ${readmePath}`) + } + + const readmeContents = fs.readFileSync(readmePath, { + encoding: 'utf-8' + }) + // replace the magic OPTION+SPACE character that messes up headers + .replaceAll(' ', ' ') + + if (!fs.existsSync(manifestPath)) { + app.logger.error(`Could not read manifest from ${manifestPath}`) + } + + const manifestContents = fs.readFileSync(manifestPath, { + encoding: 'utf-8' + }) + const manifest = JSON.parse(manifestContents) + + // parse the project's readme file + const readme = parseMarkdown(readmeContents) + const about = parseMarkdown(aboutMd) + + // remove existing header, CI link, etc + /** @type {import('mdast').Root} */ + const parsedReadme = { + type: 'root', + children: [] + } + + let foundAbout = false + let aboutHeadingLevel = -1 + + readme.children.forEach((child, index) => { + const rendered = writeMarkdown(child).toLowerCase() + + if (child.type === 'blockquote' && rendered === `> ${manifest.description.toLowerCase()}\n`) { + // insert the `About` section beneath the project description + parsedReadme.children.push( + child, + ...about.children + ) + + return + } + + if (child.type === 'heading' && rendered.includes('# about')) { + // we have found an existing `About` section - skip all content until we + // hit another heading equal or higher depth than `About + foundAbout = true + aboutHeadingLevel = child.depth + + // skip about + return + } + + if (foundAbout) { + if (child.type === 'heading' && child.depth <= aboutHeadingLevel) { + foundAbout = false + } else { + // skip all content until we hit another heading equal or higher depth + // than `About` + return + } + } + + parsedReadme.children.push(child) + }) + + const updatedReadmeContents = writeMarkdown(parsedReadme) + + fs.writeFileSync(readmePath, updatedReadmeContents, { + encoding: 'utf-8' + }) +} diff --git a/src/utils.js b/src/utils.js index 298920b71..c21ab040f 100644 --- a/src/utils.js +++ b/src/utils.js @@ -328,7 +328,7 @@ export async function everyMonorepoProject (projectDir, fn) { } /** @type {Record} */ - const projects = await parseProjects(projectDir, workspaces) + const projects = parseProjects(projectDir, workspaces) checkForCircularDependencies(projects)