nx/scripts/documentation/generators/generate-manifests.ts
Isaac Mann 84387f3611
feat(nx-dev): generate migration detail pages (#29580)
Generates list of migrations on the plugin overview page and a
standalone `/migrations` page.

To add sample code changes for a migration that has an implementation
file, create a `.md` file with the same name as the implementation file
in the same folder as the implementation file. i.e.
`move-cache-directory.md` for `move-cache-directory.ts`.

Migrations that have `packages` defined will have a table generated with
the package updates listed.

Separate PRs will be created to add sample code changes for each
migration with an implementation.

The migration list on the plugin overview page: [Angular
migrations](https://nx-dev-git-docs-migration-details-nrwl.vercel.app/nx-api/angular#migrations)
Standalone migration list page: [Angular
migrations](https://nx-dev-git-docs-migration-details-nrwl.vercel.app/nx-api/angular/migrations)
Sample migration with added markdown file details:
[17.0.0-move-cache-directory](https://nx-dev-git-docs-migration-details-nrwl.vercel.app/nx-api/nx#1700movecachedirectory)
Sample migration with only package updates: [Angular
20.4.0](https://nx-dev-git-docs-migration-details-nrwl.vercel.app/nx-api/angular#2040packageupdates)
Sample migration without any markdown file details:
[update-angular-cli-version-19-1-0](https://nx-dev-git-docs-migration-details-nrwl.vercel.app/nx-api/angular#updateangularcliversion1910)
- This last sample is very bare-bones and the reason why we need these
pages in the first place. People don't know what migrations are actually
doing. Follow up PRs will address pages like this.
2025-01-27 13:17:36 -05:00

498 lines
13 KiB
TypeScript

import * as chalk from 'chalk';
import { readJsonSync } from 'fs-extra';
import { resolve } from 'path';
import {
convertToDocumentMetadata,
createDocumentMetadata,
DocumentMetadata,
} from '@nx/nx-dev/models-document';
import { MenuItem } from '@nx/nx-dev/models-menu';
import {
PackageMetadata,
ProcessedPackageMetadata,
} from '@nx/nx-dev/models-package';
import { generateIndexMarkdownFile, generateJsonFile } from '../utils';
import { convertToDictionary } from './utils-generator/convert-to-dictionary';
interface DocumentSection {
name: string;
content: Partial<DocumentMetadata>[];
prefix: string;
}
interface DocumentManifest {
id: string;
records: Record<string, DocumentMetadata>;
}
interface PackageManifest {
id: string;
records: Record<string, ProcessedPackageMetadata>;
}
type Manifest = DocumentManifest | PackageManifest;
const isDocument = (
e: DocumentMetadata | ProcessedPackageMetadata
): e is DocumentMetadata => !('packageName' in e);
const isPackage = (
e: DocumentMetadata | ProcessedPackageMetadata
): e is ProcessedPackageMetadata => 'packageName' in e;
export function generateManifests(workspace: string): Promise<void[]> {
console.log(`${chalk.blue('i')} Generating Manifests`);
const documentationPath = resolve(workspace, 'docs');
const generatedDocumentationPath = resolve(documentationPath, 'generated');
const generatedExternalDocumentationPath = resolve(
documentationPath,
'external-generated'
);
const targetFolder: string = resolve(generatedDocumentationPath, 'manifests');
const documents: Partial<DocumentMetadata>[] = readJsonSync(
`${documentationPath}/map.json`,
{
encoding: 'utf8',
}
).content;
const packages: PackageMetadata[] = [
...readJsonSync(`${generatedDocumentationPath}/packages-metadata.json`, {
encoding: 'utf8',
}),
...readJsonSync(
`${generatedExternalDocumentationPath}/packages-metadata.json`,
{
encoding: 'utf8',
}
),
];
/**
* We are starting by selecting what section of the map.json we want to work with.
* @type {DocumentSection[]}
*/
const documentSections = createDocumentSections(documents);
/**
* Once we have the DocumentSections we can start creating our DocumentManifests.
* @type {Manifest[]}
*/
const manifests = getDocumentManifests(documentSections);
/**
* Packages are not Documents and need to be handled in a custom way.
* @type {{id: string, records: Record<string, ProcessedPackageMetadata>}}
*/
const packagesManifest = createPackagesManifest(packages);
/**
* Add the packages manifest to the manifest collection for simplicity.
*/
manifests.push(packagesManifest);
/**
* We can easily infer all Documents menus but need a custom way to handle them
* for the packages.
* @type {{id: string, menu: MenuItem[]}[]}
*/
const menus: {
id: string;
menu: MenuItem[];
}[] = getDocumentMenus(
manifests.filter((m): m is DocumentManifest =>
isDocument(m.records[Object.keys(m.records)[0]])
)
);
/**
* Creating packages menu with custom package logic.
* @type {{id: string, menu: MenuItem[]}}
*/
const packagesMenu: {
id: string;
menu: MenuItem[];
} = createPackagesMenu(packagesManifest);
/**
* Add the packages menu to the main menu collection for simplicity.
*/
menus.push(packagesMenu);
/**
* We can easily get all associated existing tags from each manifest.
* @type {Record<string, {description: string, file: string, id: string, name: string, path: string}[]>}
*/
const tags: Record<
string,
{
description: string;
file: string;
id: string;
name: string;
path: string;
}[]
> = generateTags(manifests);
/**
* We can now create manifest files.
*/
const fileGenerationPromises: Promise<any>[] = [];
manifests.forEach((manifest) =>
fileGenerationPromises.push(
generateJsonFile(
resolve(targetFolder + `/${manifest.id}.json`),
manifest.records
)
)
);
fileGenerationPromises.push(
generateJsonFile(resolve(targetFolder, `tags.json`), tags)
);
fileGenerationPromises.push(
generateJsonFile(resolve(targetFolder, `menus.json`), menus)
);
fileGenerationPromises.push(
generateIndexMarkdownFile(
resolve(documentationPath, `shared`, `reference`, `sitemap.md`),
menus
)
);
return Promise.all(fileGenerationPromises);
}
function generateTags(manifests: Manifest[]) {
const tags: Record<
string,
{
description: string;
file: string;
id: string;
name: string;
path: string;
}[]
> = {};
manifests.map((manifest) => {
for (let key in manifest.records) {
const item: DocumentMetadata | ProcessedPackageMetadata =
manifest.records[key];
if (isDocument(item))
item.tags.forEach((t) => {
const tagData = {
description: item.description,
file: item.file,
id: item.id,
name: item.name,
path: item.path,
};
!tags[t] ? (tags[t] = [tagData]) : tags[t].push(tagData);
});
if (isPackage(item))
Object.values(item.documents).forEach(
(documentMetadata: DocumentMetadata) => {
documentMetadata.tags.forEach((t: string) => {
const filePath = documentMetadata.file.startsWith(
'generated/packages'
)
? documentMetadata.file
: ['generated', 'packages', documentMetadata.file].join('/');
const tagData = {
description: documentMetadata.description,
file: filePath,
id: documentMetadata.id,
name: documentMetadata.name,
path: documentMetadata.path,
};
!tags[t] ? (tags[t] = [tagData]) : tags[t].push(tagData);
});
}
);
}
});
return tags;
}
function createPackagesMenu(packages: PackageManifest): {
id: string;
menu: MenuItem[];
} {
const packagesMenu: MenuItem[] = Object.values(packages.records).map((p) => {
const item: MenuItem = {
id: p.name,
path: '/nx-api/' + p.name,
name: p.name,
children: [],
isExternal: false,
disableCollapsible: false,
};
if (!!Object.values(p.documents).length) {
// Might need to remove the path set in the "additional api resources" items
item.children.push({
id: 'documents',
path: '/' + ['nx-api', p.name, 'documents'].join('/'),
name: 'documents',
children: Object.values(p.documents).map((d) =>
menuItemRecurseOperations(d)
),
isExternal: false,
disableCollapsible: false,
});
}
if (!!Object.values(p.executors).length) {
item.children.push({
id: 'executors',
path: '/' + ['nx-api', p.name, 'executors'].join('/'),
name: 'executors',
children: Object.values(p.executors).map((e) => ({
id: e.name,
path: '/' + ['nx-api', p.name, 'executors', e.name].join('/'),
name: e.name,
children: [],
isExternal: false,
disableCollapsible: false,
})),
isExternal: false,
disableCollapsible: false,
});
}
if (!!Object.values(p.generators).length) {
item.children.push({
id: 'generators',
path: '/' + ['nx-api', p.name, 'generators'].join('/'),
name: 'generators',
children: Object.values(p.generators).map((g) => ({
id: g.name,
path: '/' + ['nx-api', p.name, 'generators', g.name].join('/'),
name: g.name,
children: [],
isExternal: false,
disableCollapsible: false,
})),
isExternal: false,
disableCollapsible: false,
});
}
if (!!Object.values(p.migrations).length) {
item.children.push({
id: 'migrations',
path: '/' + ['nx-api', p.name, 'migrations'].join('/'),
name: 'migrations',
children: [],
isExternal: false,
disableCollapsible: false,
});
}
return item;
});
return { id: 'nx-api', menu: packagesMenu };
}
function getDocumentMenus(manifests: DocumentManifest[]): {
id: string;
menu: MenuItem[];
}[] {
return manifests.map((record) => ({
id: record.id,
menu: Object.values(record.records)
.map((item: any) => convertToDocumentMetadata(item))
.map((item: DocumentMetadata) => menuItemRecurseOperations(item)),
}));
}
function createPackagesManifest(packages: PackageMetadata[]): {
id: string;
records: Record<string, ProcessedPackageMetadata>;
} {
const packagesManifest: {
id: string;
records: Record<string, ProcessedPackageMetadata>;
} = { id: 'nx-api', records: {} };
packages.forEach((p) => {
packagesManifest.records[p.name] = {
githubRoot: p.githubRoot,
name: p.name,
packageName: p.packageName,
description: p.description,
documents: convertToDictionary(
p.documents.map((d) =>
documentRecurseOperations(
d,
createDocumentMetadata({ id: p.name, path: 'nx-api/' })
)
),
'path'
),
root: p.root,
source: p.source,
executors: convertToDictionary(
p.executors.map((e) => ({
...e,
path: generatePath({ id: e.name, path: e.path }, 'nx-api'),
})),
'path'
),
generators: convertToDictionary(
p.generators.map((g) => ({
...g,
path: generatePath({ id: g.name, path: g.path }, 'nx-api'),
})),
'path'
),
migrations: convertToDictionary(
p.migrations.map((g) => ({
...g,
path: generatePath({ id: g.name, path: g.path }, 'nx-api'),
})),
'path'
),
path: generatePath({ id: p.name, path: '' }, 'nx-api'),
};
});
return packagesManifest;
}
function getDocumentManifests(sections: DocumentSection[]): Manifest[] {
return sections.map((section) => {
const records: Record<string, DocumentMetadata> = {};
section.content
.map((item: any) => convertToDocumentMetadata(item))
.map((item: DocumentMetadata) =>
documentRecurseOperations(
item,
createDocumentMetadata({ id: section.name, path: section.prefix })
)
)
.forEach((item: DocumentMetadata) => {
populateDictionary(item, records);
});
return {
id: section.name,
records,
};
});
}
function createDocumentSections(
documents: Partial<DocumentMetadata>[]
): DocumentSection[] {
return [
{
name: 'nx',
content: documents.find((x) => x.id === 'nx-documentation')!
.itemList as Partial<DocumentMetadata>[],
prefix: '',
},
{
name: 'extending-nx',
content: documents.find((x) => x.id === 'extending-nx')!
.itemList as Partial<DocumentMetadata>[],
prefix: 'extending-nx',
},
{
name: 'ci',
content: documents.find((x) => x.id === 'ci')!
.itemList as Partial<DocumentMetadata>[],
prefix: 'ci',
},
];
}
function generatePath(
item: { path: string; id: string },
prefix: string = ''
): string {
const isLinkExternal: (p: string) => boolean = (p: string) =>
p.startsWith('http');
const isLinkAbsolute: (p: string) => boolean = (p: string) =>
p.startsWith('/');
if (!item.path)
return '/' + [...prefix.split('/'), item.id].filter(Boolean).join('/');
if (isLinkAbsolute(item.path) || isLinkExternal(item.path)) return item.path;
return (
'/' +
[...prefix.split('/'), ...item.path.split('/')].filter(Boolean).join('/')
);
}
/**
* Handle data interpolation for all items and their children.
* @param target
* @param parent
*/
function documentRecurseOperations(
target: DocumentMetadata,
parent: DocumentMetadata
): DocumentMetadata {
const document: DocumentMetadata = structuredClone(target);
/**
* Calculate `path`
*/
if (!!parent) document.path = generatePath(target, parent.path);
else document.path = generatePath(document);
/**
* Calculate `isExternal`
*/
if (document['isExternal'] === undefined) {
document.isExternal = document.path.startsWith('http');
}
if (!!target.itemList.length) {
document.itemList = target.itemList.map((i) =>
documentRecurseOperations(i, document)
);
}
return document;
}
function populateDictionary(
document: DocumentMetadata,
dictionary: Record<string, DocumentMetadata>
) {
if (document.path.startsWith('http')) return;
dictionary[document.path] = document;
document.itemList.forEach((item: DocumentMetadata) =>
populateDictionary(item, dictionary)
);
}
// Creates menus dictionary mapping
function menuItemRecurseOperations(target: DocumentMetadata): MenuItem {
const menuItem: MenuItem = {
name: target.name,
path: target.path,
id: target.id,
isExternal: target.isExternal,
children: [],
disableCollapsible: false,
};
/**
* Calculate `isExternal`
*/
if (menuItem['isExternal'] === undefined) {
menuItem.isExternal = menuItem.path.startsWith('http');
}
if (!!target.itemList.length) {
menuItem.children = target.itemList.map((i) =>
menuItemRecurseOperations(i)
);
}
return menuItem;
}