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

185 lines
5.3 KiB
TypeScript

import { TagsApi } from '@nx/nx-dev/data-access-documents/node-only';
import { DocumentMetadata } from '@nx/nx-dev/models-document';
import {
FileMetadata,
IntrinsicPackageMetadata,
ProcessedPackageMetadata,
SchemaMetadata,
} from '@nx/nx-dev/models-package';
import { readFileSync, lstatSync, readdirSync } from 'fs';
import { join } from 'path';
interface StaticDocumentPaths {
params: { segments: string[] };
}
export class PackagesApi {
private readonly manifest: Record<string, ProcessedPackageMetadata>;
constructor(
private readonly options: {
id: string;
manifest: Record<string, ProcessedPackageMetadata>;
prefix: string;
publicDocsRoot: string;
tagsApi: TagsApi;
}
) {
if (!options.id) {
throw new Error('id cannot be undefined');
}
if (!options.prefix) {
options.prefix = '';
}
if (!options.publicDocsRoot) {
throw new Error('public docs root cannot be undefined');
}
if (!options.manifest) {
throw new Error('public document sources cannot be undefined');
}
this.manifest = structuredClone(this.options.manifest);
}
getFilePath(path: string): string {
return join(this.options.publicDocsRoot, path);
}
/**
* Give a list of available segments/paths for the Nextjs app.
*/
getStaticDocumentPaths(): {
packages: StaticDocumentPaths[];
documents: StaticDocumentPaths[];
executors: StaticDocumentPaths[];
generators: StaticDocumentPaths[];
migrations: StaticDocumentPaths[];
} {
/**
* TODO: Extract this into utils, can be used by DocumentsAPI as well.
* Generate a Nextjs Segments Param from a path and prefix (optional)
* @param {string} path
* @param {string} prefix
* @returns {StaticDocumentPaths}
*/
function generateSegments(
path: string,
prefix: string = ''
): StaticDocumentPaths {
const segments = path.split('/').filter(Boolean).flat();
return {
params: {
segments: !!prefix ? [prefix].concat(segments) : segments,
},
};
}
const packages = Object.values(this.manifest);
const experiment: {
packages: StaticDocumentPaths[];
documents: StaticDocumentPaths[];
executors: StaticDocumentPaths[];
generators: StaticDocumentPaths[];
migrations: StaticDocumentPaths[];
} = {
packages: [],
documents: [],
executors: [],
generators: [],
migrations: [],
};
packages.forEach((p) => {
experiment.packages.push(generateSegments(p.path, this.options.prefix));
Object.keys(p.documents).map((path) =>
experiment.documents.push(generateSegments(path, this.options.prefix))
);
if (p.name === 'devkit') {
readdirSync('../../docs/generated/devkit').forEach((fileName) => {
if (fileName.endsWith('.md')) {
experiment.documents.push(
generateSegments(
`packages/devkit/documents/${fileName.replace('.md', '')}`,
this.options.prefix
)
);
} else {
readdirSync('../../docs/generated/devkit/' + fileName).forEach(
(subFileName) => {
experiment.documents.push(
generateSegments(
`packages/devkit/documents/${fileName}/${subFileName.replace(
'.md',
''
)}`,
this.options.prefix
)
);
}
);
}
});
}
Object.keys(p.executors).forEach((path) =>
experiment.executors.push(generateSegments(path, this.options.prefix))
);
Object.keys(p.generators).forEach((path) =>
experiment.generators.push(generateSegments(path, this.options.prefix))
);
Object.keys(p.migrations).forEach((path) =>
experiment.migrations.push(generateSegments(path, this.options.prefix))
);
});
return experiment;
}
getPackage(path: string[]): ProcessedPackageMetadata {
const pkg: ProcessedPackageMetadata | null =
this.manifest[path.join('/')] || null;
if (!pkg)
throw new Error(
`Package not found in manifest with: "${path.join('/')}"`
);
return {
...pkg,
description: pkg.documents['overview']
? readFileSync(pkg.documents['overview'].file, 'utf-8')
: pkg.description,
};
}
getPackageDocuments(name: string): Record<string, DocumentMetadata> {
return this.manifest[name]['documents'];
}
getPackageFileMetadatas(
name: string,
type: 'executors' | 'generators' | 'migrations'
): Record<string, FileMetadata> {
return this.manifest[name][type];
}
getSchemaMetadata(fileMetadata: FileMetadata): SchemaMetadata {
return JSON.parse(
readFileSync(this.getFilePath(fileMetadata.file), 'utf-8')
);
}
getRootPackageIndex(): IntrinsicPackageMetadata[] {
return Object.keys(this.manifest).map((k) => ({
description: this.manifest[k].description,
githubRoot: this.manifest[k].githubRoot,
name: this.manifest[k].name,
packageName: this.manifest[k].packageName,
path: this.manifest[k].path,
root: this.manifest[k].root,
source: this.manifest[k].source,
}));
}
}